diff --git a/DESCRIPTION b/DESCRIPTION index c768b3f..1547392 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -19,7 +19,7 @@ Suggests: withr Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 Config/testthat/edition: 3 URL: https://github.com/posit-dev/connectcreds BugReports: https://github.com/posit-dev/connectcreds/issues diff --git a/NEWS.md b/NEWS.md index 51f4770..9d8450c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,12 @@ # connectcreds (development version) +* `connect_service_account_token()` and `connect_workload_token()` now prefer + to read the content session token from the file pointed to by the + `CONNECT_CONTENT_SESSION_TOKEN_FILE` environment variable, falling back to + the `CONNECT_CONTENT_SESSION_TOKEN` environment variable. This helps + long-running processes maintain fresh credentials on Connect 2026.05.0 and + later (#10). + # connectcreds 0.2.0 * New `connect_service_account_token()` and `has_service_account_token()` diff --git a/R/mocking.R b/R/mocking.R index 6d18f55..8d2257b 100644 --- a/R/mocking.R +++ b/R/mocking.R @@ -37,6 +37,7 @@ with_mocked_connect_responses <- function( CONNECT_SERVER = "localhost:3030", CONNECT_API_KEY = "key", CONNECT_CONTENT_SESSION_TOKEN = "session-token", + CONNECT_CONTENT_SESSION_TOKEN_FILE = NA, CONNECTCREDS_MOCKING = "1", .local_envir = env ), @@ -62,6 +63,7 @@ local_mocked_connect_responses <- function( CONNECT_SERVER = "localhost:3030", CONNECT_API_KEY = "key", CONNECT_CONTENT_SESSION_TOKEN = "session-token", + CONNECT_CONTENT_SESSION_TOKEN_FILE = NA, CONNECTCREDS_MOCKING = "1", .local_envir = env ) diff --git a/R/service-accounts.R b/R/service-accounts.R index cd8f828..9c2c58a 100644 --- a/R/service-accounts.R +++ b/R/service-accounts.R @@ -9,8 +9,11 @@ #' @inheritParams httr2::oauth_flow_token_exchange #' @inheritParams connect_viewer_token #' @param content_token A token that uniquely identifies this content session. -#' Defaults to the value of the `CONNECT_CONTENT_SESSION_TOKEN` environment -#' variable, which is set automatically when running on Connect. +#' Defaults to the value found in the file indicated by the +#' `CONNECT_CONTENT_SESSION_TOKEN_FILE` environment variable on Connect +#' 2026.05.0 and later, or the value of the `CONNECT_CONTENT_SESSION_TOKEN` +#' environment variable on earlier versions. These are set automatically when +#' running on Connect. #' @returns [connect_service_account_token()] returns an [httr2::oauth_token]. #' @examples #' token <- "default-token" @@ -21,7 +24,7 @@ connect_service_account_token <- function( resource = NULL, scope = NULL, - content_token = Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN"), + content_token = content_session_token(), server_url = Sys.getenv("CONNECT_SERVER"), api_key = Sys.getenv("CONNECT_API_KEY") ) { @@ -105,7 +108,7 @@ has_service_account_token <- function(...) { #' @export connect_workload_token <- function( ..., - content_token = Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN"), + content_token = content_session_token(), server_url = Sys.getenv("CONNECT_SERVER"), api_key = Sys.getenv("CONNECT_API_KEY") ) { @@ -169,3 +172,22 @@ has_workload_token <- function(...) { } ) } + +#' Look up the content session token. +#' +#' Prefer reading from the file pointed to by the +#' `CONNECT_CONTENT_SESSION_TOKEN_FILE` environment variable, since long-running +#' processes need to be able to pick up a refreshed token. Fall back to the +#' `CONNECT_CONTENT_SESSION_TOKEN` environment variable for older versions of +#' Connect that don't supply a token file. +#' @noRd +content_session_token <- function() { + token_file <- Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN_FILE") + if (nzchar(token_file) && file.exists(token_file)) { + token <- readLines(token_file, n = 1, warn = FALSE) + if (length(token) >= 1 && nzchar(token[[1]])) { + return(token[[1]]) + } + } + Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN") +} diff --git a/man/connect_service_account_token.Rd b/man/connect_service_account_token.Rd index 319c0cb..6977bf5 100644 --- a/man/connect_service_account_token.Rd +++ b/man/connect_service_account_token.Rd @@ -8,7 +8,7 @@ connect_service_account_token( resource = NULL, scope = NULL, - content_token = Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN"), + content_token = content_session_token(), server_url = Sys.getenv("CONNECT_SERVER"), api_key = Sys.getenv("CONNECT_API_KEY") ) @@ -22,8 +22,11 @@ trying to access, if applicable.} \item{scope}{Scopes to be requested from the resource owner.} \item{content_token}{A token that uniquely identifies this content session. -Defaults to the value of the \code{CONNECT_CONTENT_SESSION_TOKEN} environment -variable, which is set automatically when running on Connect.} +Defaults to the value found in the file indicated by the +\code{CONNECT_CONTENT_SESSION_TOKEN_FILE} environment variable on Connect +2026.05.0 and later, or the value of the \code{CONNECT_CONTENT_SESSION_TOKEN} +environment variable on earlier versions. These are set automatically when +running on Connect.} \item{server_url}{The Connect server to exchange credentials with. Defaults to the value of the \code{CONNECT_SERVER} environment variable, which is set diff --git a/man/connect_workload_token.Rd b/man/connect_workload_token.Rd index fdb5a40..0ec3837 100644 --- a/man/connect_workload_token.Rd +++ b/man/connect_workload_token.Rd @@ -7,7 +7,7 @@ \usage{ connect_workload_token( ..., - content_token = Sys.getenv("CONNECT_CONTENT_SESSION_TOKEN"), + content_token = content_session_token(), server_url = Sys.getenv("CONNECT_SERVER"), api_key = Sys.getenv("CONNECT_API_KEY") ) @@ -18,8 +18,11 @@ has_workload_token(...) \item{...}{Further arguments passed on to \code{\link[=connect_workload_token]{connect_workload_token()}}.} \item{content_token}{A token that uniquely identifies this content session. -Defaults to the value of the \code{CONNECT_CONTENT_SESSION_TOKEN} environment -variable, which is set automatically when running on Connect.} +Defaults to the value found in the file indicated by the +\code{CONNECT_CONTENT_SESSION_TOKEN_FILE} environment variable on Connect +2026.05.0 and later, or the value of the \code{CONNECT_CONTENT_SESSION_TOKEN} +environment variable on earlier versions. These are set automatically when +running on Connect.} \item{server_url}{The Connect server to exchange credentials with. Defaults to the value of the \code{CONNECT_SERVER} environment variable, which is set diff --git a/tests/testthat/test-service-accounts.R b/tests/testthat/test-service-accounts.R index a91e9ae..dbcad33 100644 --- a/tests/testthat/test-service-accounts.R +++ b/tests/testthat/test-service-accounts.R @@ -48,6 +48,75 @@ test_that("mock Connect responses work as expected", { ) }) +test_that("session token from file is preferred over env var", { + token_file <- withr::local_tempfile() + writeLines("token-from-file", token_file) + withr::local_envvar( + CONNECT_CONTENT_SESSION_TOKEN_FILE = token_file, + CONNECT_CONTENT_SESSION_TOKEN = "token-from-envvar" + ) + expect_equal(content_session_token(), "token-from-file") +}) + +test_that("session token falls back to env var when file is empty", { + token_file <- withr::local_tempfile() + file.create(token_file) + withr::local_envvar( + CONNECT_CONTENT_SESSION_TOKEN_FILE = token_file, + CONNECT_CONTENT_SESSION_TOKEN = "token-from-envvar" + ) + expect_equal(content_session_token(), "token-from-envvar") +}) + +test_that("session token falls back to env var when file is missing", { + withr::local_envvar( + CONNECT_CONTENT_SESSION_TOKEN_FILE = "/nonexistent/path/to/token", + CONNECT_CONTENT_SESSION_TOKEN = "token-from-envvar" + ) + expect_equal(content_session_token(), "token-from-envvar") +}) + +test_that("session token falls back to env var when file env is unset", { + withr::local_envvar( + CONNECT_CONTENT_SESSION_TOKEN_FILE = NA, + CONNECT_CONTENT_SESSION_TOKEN = "token-from-envvar" + ) + expect_equal(content_session_token(), "token-from-envvar") +}) + +test_that("session token is empty when neither source is available", { + withr::local_envvar( + CONNECT_CONTENT_SESSION_TOKEN_FILE = NA, + CONNECT_CONTENT_SESSION_TOKEN = NA + ) + expect_equal(content_session_token(), "") +}) + +test_that("connect_service_account_token() reads token from file", { + local_mocked_connect_responses(function(req) { + expect_equal(as.character(req$body$data$subject_token), "token-from-file") + response_json(body = list(access_token = "exchanged")) + }) + token_file <- withr::local_tempfile() + writeLines("token-from-file", token_file) + withr::local_envvar(CONNECT_CONTENT_SESSION_TOKEN_FILE = token_file) + expect_equal(connect_service_account_token()$access_token, "exchanged") +}) + +test_that("connect_workload_token() reads token from file", { + local_mocked_connect_responses(function(req) { + expect_equal(as.character(req$body$data$subject_token), "token-from-file") + response_json(body = list(access_token = "exchanged")) + }) + token_file <- withr::local_tempfile() + writeLines("token-from-file", token_file) + withr::local_envvar(CONNECT_CONTENT_SESSION_TOKEN_FILE = token_file) + expect_equal( + connect_workload_token(audience = "test")$access_token, + "exchanged" + ) +}) + test_that("has_workload_token() returns false when not on Connect", { expect_false(has_workload_token(audience = "test")) })