Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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()`
Expand Down
2 changes: 2 additions & 0 deletions R/mocking.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand All @@ -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
)
Expand Down
30 changes: 26 additions & 4 deletions R/service-accounts.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
) {
Expand Down Expand Up @@ -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")
) {
Expand Down Expand Up @@ -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")
}
9 changes: 6 additions & 3 deletions man/connect_service_account_token.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions man/connect_workload_token.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 69 additions & 0 deletions tests/testthat/test-service-accounts.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
})
Expand Down
Loading