Skip to content

Commit 05fd8d5

Browse files
authored
feat: add support for visitor api key (#363)
1 parent 5bf7f59 commit 05fd8d5

File tree

9 files changed

+194
-19
lines changed

9 files changed

+194
-19
lines changed

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
(#341)
77
- New `get_job_list()` function returns a list of jobs for a content item.
88
(#341)
9+
- New `token` parameter to `connect()` function allows you to create a Connect
10+
client with permissions scoped to the content visitor when running on a Connect
11+
server. (#362)
912

1013
## Newly deprecated
1114

R/connect.R

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -888,14 +888,31 @@ Connect <- R6::R6Class(
888888

889889
#' Create a connection to Posit Connect
890890
#'
891-
#' Creates a connection to Posit Connect using the server URL and an api key.
891+
#' Creates a connection to Posit Connect using the server URL and an API key.
892892
#' Validates the connection and checks that the version of the server is
893893
#' compatible with the current version of the package.
894894
#'
895+
#' When running on Connect, the client's environment will contain default
896+
#' `CONNECT_SERVER` and `CONNECT_API_KEY` variables. The API key's permissions
897+
#' are scoped to the publishing user's account.
898+
#'
899+
#' To create a client with permissions scoped to the content visitor's account,
900+
#' call `connect()` passing a user session token from content session headers
901+
#' to the `token` argument. To do this, you must first add a Connect API
902+
#' integration in your published content's Access sidebar.
903+
#'
895904
#' @param server The URL for accessing Posit Connect. Defaults to environment
896905
#' variable CONNECT_SERVER
897906
#' @param api_key The API Key to authenticate to Posit Connect with. Defaults
898907
#' to environment variable CONNECT_API_KEY
908+
#' @param token Optional. A user session token. When running on a Connect server,
909+
#' creates a client using the content visitor's account. Running locally, the
910+
#' created client uses the provided API key.
911+
#' @param token_local_testing_key Optional. Only used when not running on
912+
#' Connect and a `token` is provided. By default, the function returns a
913+
#' `Connect` object using the `api_key`. By providing a different
914+
#' key here you can test a visitor client with differently-scoped
915+
#' permissions.
899916
#' @param prefix The prefix used to determine environment variables
900917
#' @param ... Additional arguments. Not used at present
901918
#' @param .check_is_fatal Whether to fail if "check" requests fail. Useful in
@@ -907,7 +924,17 @@ Connect <- R6::R6Class(
907924
#'
908925
#' @examples
909926
#' \dontrun{
910-
#' connect()
927+
#' client <- connect()
928+
#'
929+
#' # Running in Connect, create a client using the content visitor's account.
930+
#' # This example assumes code is being executed in a Shiny app's `server`
931+
#' # function with a `session` object available.
932+
#' token <- session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN
933+
#' client <- connect(token = token)
934+
#'
935+
#' # Test locally with an API key using a different role.
936+
#' fallback_key <- Sys.getenv("VIEWER_ROLE_API_KEY")
937+
#' client <- connect(token = token, token_local_testing_key = fallback_key)
911938
#' }
912939
#'
913940
#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true")
@@ -920,6 +947,8 @@ Connect <- R6::R6Class(
920947
connect <- function(
921948
server = Sys.getenv(paste0(prefix, "_SERVER"), NA_character_),
922949
api_key = Sys.getenv(paste0(prefix, "_API_KEY"), NA_character_),
950+
token,
951+
token_local_testing_key = api_key,
923952
prefix = "CONNECT",
924953
...,
925954
.check_is_fatal = TRUE) {
@@ -933,6 +962,24 @@ connect <- function(
933962
}
934963
con <- Connect$new(server = server, api_key = api_key)
935964

965+
if (!missing(token)) {
966+
error_if_less_than(con$version, "2025.01.0")
967+
if (on_connect()) {
968+
visitor_creds <- get_oauth_credentials(
969+
con,
970+
user_session_token = token,
971+
requested_token_type = "urn:posit:connect:api-key"
972+
)
973+
con <- connect(server = server, api_key = visitor_creds$access_token)
974+
} else {
975+
con <- connect(server = server, api_key = token_local_testing_key)
976+
message(paste0(
977+
"Called with `token` but not running on Connect. ",
978+
"Continuing with fallback API key."
979+
))
980+
}
981+
}
982+
936983
tryCatch(
937984
{
938985
check_connect_license(con)

R/get.R

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,12 +579,15 @@ get_procs <- function(src) {
579579
return(tbl_data)
580580
}
581581

582-
#' Perform an OAuth credential exchange to obtain a viewer's OAuth access token.
582+
#' Perform an OAuth credential exchange to obtain a visitor's OAuth access token.
583583
#'
584584
#' @param connect A Connect R6 object.
585-
#' @param user_session_token The content viewer's session token. This token
585+
#' @param user_session_token The content visitor's session token. This token
586586
#' can only be obtained when the content is running on a Connect server. The token
587587
#' identifies the user who is viewing the content interactively on the Connect server.
588+
#' @param requested_token_type Optional. You may pass `"urn:posit:connect:api-key"` to
589+
#' request an ephemeral Connect API key scoped to the content visitor's account.
590+
#'
588591
#'
589592
#' Read this value from the HTTP header: `Posit-Connect-User-Session-Token`
590593
#'
@@ -612,13 +615,14 @@ get_procs <- function(src) {
612615
#' for more information.
613616
#'
614617
#' @export
615-
get_oauth_credentials <- function(connect, user_session_token) {
618+
get_oauth_credentials <- function(connect, user_session_token, requested_token_type = NULL) {
616619
validate_R6_class(connect, "Connect")
617620
url <- v1_url("oauth", "integrations", "credentials")
618621
body <- list(
619622
grant_type = "urn:ietf:params:oauth:grant-type:token-exchange",
620623
subject_token_type = "urn:posit:connect:user-session-token",
621-
subject_token = user_session_token
624+
subject_token = user_session_token,
625+
requested_token_type = requested_token_type
622626
)
623627
connect$POST(
624628
url,

R/utils.R

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,9 @@ endpoint_does_not_exist <- function(res) {
194194
!("code" %in% names(httr::content(res, as = "parsed")))
195195
)
196196
}
197+
198+
# Returns `TRUE` if we're running on Connect as determined by the
199+
# `RSTUDIO_PRODUCT` env var, else `FALSE`.
200+
on_connect <- function() {
201+
Sys.getenv("RSTUDIO_PRODUCT") == "CONNECT"
202+
}

man/connect.Rd

Lines changed: 34 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/connectapi-package.Rd

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/get_oauth_credentials.Rd

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"access_token": "visitor-api-key",
3+
"issued_token_type": "urn:posit:connect:api-key",
4+
"token_type": "Key"
5+
}

tests/testthat/test-connect.R

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,78 @@ test_that("client$version is returns version when server settings exposes it", {
115115
expect_equal(con$version, "2024.09.0")
116116
})
117117
})
118+
119+
test_that("Visitor client can successfully be created running on Connect", {
120+
with_mock_api({
121+
withr::local_envvar(
122+
CONNECT_SERVER = "https://connect.example",
123+
CONNECT_API_KEY = "fake",
124+
RSTUDIO_PRODUCT = "CONNECT"
125+
)
126+
127+
client <- connect(token = "my-token")
128+
129+
expect_equal(
130+
client$server,
131+
"https://connect.example"
132+
)
133+
expect_equal(
134+
client$api_key,
135+
"visitor-api-key"
136+
)
137+
})
138+
})
139+
140+
test_that("Visitor client uses fallback api key when running locally", {
141+
with_mock_api({
142+
withr::local_envvar(
143+
CONNECT_SERVER = "https://connect.example",
144+
CONNECT_API_KEY = "fake"
145+
)
146+
147+
# With default fallback
148+
expect_message(
149+
client <- connect(token = NULL),
150+
"Called with `token` but not running on Connect. Continuing with fallback API key."
151+
)
152+
153+
expect_equal(
154+
client$server,
155+
"https://connect.example"
156+
)
157+
expect_equal(
158+
client$api_key,
159+
"fake"
160+
)
161+
162+
# With explicitly-defined fallback
163+
expect_message(
164+
client <- connect(token = NULL, token_local_testing_key = "fallback_fake"),
165+
"Called with `token` but not running on Connect. Continuing with fallback API key."
166+
)
167+
168+
expect_equal(
169+
client$server,
170+
"https://connect.example"
171+
)
172+
expect_equal(
173+
client$api_key,
174+
"fallback_fake"
175+
)
176+
})
177+
})
178+
179+
test_that("Visitor client code path errs with older Connect version", {
180+
with_mock_dir("2024.09.0", {
181+
withr::local_envvar(
182+
CONNECT_SERVER = "https://connect.example",
183+
CONNECT_API_KEY = "fake",
184+
RSTUDIO_PRODUCT = "CONNECT"
185+
)
186+
187+
expect_error(
188+
client <- connect(token = "my-token"),
189+
"This feature requires Posit Connect version 2025.01.0 but you are using 2024.09.0"
190+
)
191+
})
192+
})

0 commit comments

Comments
 (0)