Skip to content

Commit 72ff6cf

Browse files
committed
Add support for viewer-based credentials on Posit Connect.
This commit wires up a new credential provider for Connect's viewer-based credentials feature [0]. Most of the actual work is outsourced to a new shared package, `connectcreds` [1]. Viewer credentials are inherently tied to a given Shiny session, which is at odds with `gargle`'s existing view that a single credential is active for a given R process -- so we need to unwind this assumption. In order to support storing and retrieving "session" credentials from the existing `AuthState` object -- while preserving backward- and forward-compatibility with existing packages -- I have modified its API so that setters and getters are now aware of the existince of session credentials. (There are comprehensive unit tests that explain the details.) Existing packages need to be updated to use this getter, if they aren't already (most are not), but in the meantime they will continue to work -- though they won't be able to use viewer-based credentials. This seems like a reasonable tradeoff, and allows us to say e.g. "upgrade `bigrquery` if you want to use it with Connect's viewer-based credentials". Unit tests are included. [0]: https://docs.posit.co/connect/user/oauth-integrations/ [1]: https://github.com/posit-dev/connectcreds/ Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent ef00eb0 commit 72ff6cf

18 files changed

+404
-13
lines changed

DESCRIPTION

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ Imports:
3535
Suggests:
3636
aws.ec2metadata,
3737
aws.signature,
38+
connectcreds,
3839
covr,
3940
httpuv,
41+
httr2,
4042
knitr,
4143
rmarkdown,
4244
sodium,
@@ -50,3 +52,5 @@ Encoding: UTF-8
5052
Language: en-US
5153
Roxygen: list(markdown = TRUE)
5254
RoxygenNote: 7.2.3
55+
Remotes:
56+
posit-dev/connectcreds

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export(cred_funs_set)
2020
export(cred_funs_set_default)
2121
export(credentials_app_default)
2222
export(credentials_byo_oauth2)
23+
export(credentials_connect)
2324
export(credentials_external_account)
2425
export(credentials_gce)
2526
export(credentials_service_account)

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# gargle (development version)
22

3+
* gargle can now pick up on Google credentials from the current Shiny session
4+
when running on Posit Connect (@atheriel, #289).
5+
36
# gargle 1.5.2
47

58
* Fixed a bug in an internal helper that validates input specifying a service

R/AuthState-class.R

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ AuthState <- R6::R6Class("AuthState", list(
106106
api_key = NULL,
107107
#' @field auth_active Logical, indicating whether auth is active.
108108
auth_active = NULL,
109-
#' @field cred Credentials.
109+
#' @field cred Global credentials.
110110
cred = NULL,
111111
#' @description Create a new AuthState
112112
#' @details For more details on the parameters, see [init_AuthState()]
@@ -200,25 +200,58 @@ AuthState <- R6::R6Class("AuthState", list(
200200
},
201201
#' @description Set credentials
202202
#' @param cred User credentials.
203-
set_cred = function(cred) {
204-
self$cred <- cred
203+
#' @param id An identifier for these credentials, or `NULL` to set the global
204+
#' credentials.
205+
set_cred = function(cred, id = NULL) {
206+
if (hasName(cred, "id")) {
207+
id <- cred$id
208+
}
209+
if (!is.null(id)) {
210+
env_poke(private$session_creds, hash(id), cred)
211+
} else {
212+
self$cred <- cred
213+
}
205214
invisible(self)
206215
},
207216
#' @description Clear credentials
208-
clear_cred = function() {
209-
self$set_cred(NULL)
217+
#' @param id An identifier for the credentials, or `NULL` to clear the global
218+
#' credentials.
219+
clear_cred = function(id = current_session_id()) {
220+
if (!is.null(id) && env_has(private$session_creds, hash(id))) {
221+
env_unbind(private$session_creds, hash(id))
222+
} else {
223+
self$cred <- NULL
224+
}
225+
invisible(self)
210226
},
211227
#' @description Get credentials
212-
get_cred = function() {
228+
#' @param id An identifier for the credentials, or `NULL` to get the global
229+
#' credentials.
230+
get_cred = function(id = current_session_id()) {
231+
if (!is.null(id)) {
232+
cred <- env_get(private$session_creds, hash(id), default = NULL)
233+
if (!is.null(cred)) {
234+
return(cred)
235+
}
236+
}
213237
self$cred
214238
},
215239
#' @description Report if we have credentials
216-
has_cred = function() {
240+
#' @param id An identifier for the credentials, or `NULL` to check the global
241+
#' credentials.
242+
has_cred = function(id = current_session_id()) {
217243
## FIXME(jennybc): how should this interact with auth_active? should it?
218-
!is.null(self$cred)
244+
!is.null(self$get_cred(id = id))
219245
}
246+
), private = list(
247+
session_creds = new_environment()
220248
))
221249

250+
current_session_id <- function() {
251+
# For now, only Connect's notion of a session is relevant.
252+
connect_session_id()
253+
}
254+
222255
make_package_hint <- function(pkg) {
223256
hint <- NULL
224257
if (is_string(pkg)) {

R/cred_funs.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ cred_funs_clear <- function() {
142142
cred_funs_list_default <- function() {
143143
list(
144144
credentials_byo_oauth2 = credentials_byo_oauth2,
145+
credentials_connect = credentials_connect,
145146
credentials_service_account = credentials_service_account,
146147
credentials_external_account = credentials_external_account,
147148
credentials_app_default = credentials_app_default,

R/credentials_connect.R

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#' Get a token on Posit Connect
2+
#'
3+
#' @description
4+
#'
5+
#' `r lifecycle::badge('experimental')`
6+
#'
7+
#' Shiny apps running on Posit Connect [can retrieve Google credentials for each
8+
#' individual viewer](https://docs.posit.co/connect/user/oauth-integrations/).
9+
#'
10+
#' Requires the \pkg{connectcreds} package.
11+
#'
12+
#' @inheritParams token_fetch
13+
#' @returns A [httr::Token2.0()] or `NULL`.
14+
#' @family credential functions
15+
#' @examples
16+
#' credentials_connect()
17+
#' @export
18+
credentials_connect <- function(scopes = NULL, ...) {
19+
gargle_debug("trying {.fun credentials_connect}")
20+
if (!identical(Sys.getenv("RSTUDIO_PRODUCT"), "CONNECT")) {
21+
gargle_debug(c("x" = "We don't seem to be on Posit Connect."))
22+
return(NULL)
23+
}
24+
session <- current_shiny_session()
25+
if (is.null(session)) {
26+
gargle_debug(c("x" = "Viewer-based credentials only work in Shiny."))
27+
return(NULL)
28+
}
29+
if (!is_installed("connectcreds")) {
30+
gargle_debug(c(
31+
"x" = "Viewer-based credentials require the {.pkg connectcreds} package,\
32+
but it is not installed.",
33+
"i" = "Redeploy with {.pkg connectcreds} as a dependency if you wish to \
34+
use viewer-based credentials. The most common way to do this is \
35+
to add {.code library(connectcreds)} to your {.file app.R} file."
36+
))
37+
return(NULL)
38+
}
39+
token <- ConnectToken$new(session, scopes = normalize_scopes(scopes))
40+
gargle_debug("Connect token: {.val {token$id}}")
41+
token
42+
}
43+
44+
current_shiny_session <- function() {
45+
if (!isNamespaceLoaded("shiny")) {
46+
return(NULL)
47+
}
48+
# Avoid taking a Suggests dependency on Shiny, which is otherwise irrelevant
49+
# to gargle.
50+
f <- get("getDefaultReactiveDomain", envir = asNamespace("shiny"))
51+
f()
52+
}
53+
54+
connect_session_id <- function(session = current_shiny_session()) {
55+
if (is.null(session)) {
56+
return(NULL)
57+
}
58+
session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN
59+
}
60+
61+
#' @noRd
62+
ConnectToken <- R6::R6Class("ConnectToken", inherit = httr::Token2.0, list(
63+
#' @field id The session identifier associated with this token.
64+
id = NULL,
65+
66+
#' @description Get a token on Posit Connect.
67+
#' @param session A Shiny session.
68+
#' @param scopes A list of scopes to request for the token.
69+
#' @return A ConnectToken.
70+
initialize = function(session, scopes = NULL) {
71+
gargle_debug("ConnectToken initialize")
72+
self$id <- connect_session_id(session)
73+
self$params <- list(scopes = scopes)
74+
private$session <- session
75+
self$init_credentials()
76+
},
77+
78+
#' @description Enact the actual token exchange with Posit Connect.
79+
init_credentials = function() {
80+
gargle_debug("ConnectToken init_credentials")
81+
scope <- NULL
82+
if (!is.null(self$params$scopes)) {
83+
scope <- paste(self$params$scopes, collapse = " ")
84+
}
85+
self$credentials <- connectcreds::connect_viewer_token(
86+
private$session,
87+
scope = scope
88+
)
89+
self
90+
},
91+
92+
#' @description Refreshes the token, which means re-doing the entire token
93+
#' flow in this case.
94+
refresh = function() {
95+
gargle_debug("ConnectToken refresh")
96+
# This is a slight misuse of httr's notion of "refreshing" a token, but it
97+
# works in most cases.
98+
self$init_credentials()
99+
},
100+
101+
#' @description Format a [ConnectToken()].
102+
#' @param ... Not used.
103+
format = function(...) {
104+
x <- list(
105+
id = self$id,
106+
scopes = self$params$scopes,
107+
credentials = commapse(names(self$credentials))
108+
)
109+
c(
110+
cli::cli_format_method(
111+
cli::cli_h1("<ConnectToken (via {.pkg gargle})>")
112+
),
113+
glue("{fr(names(x))}: {fl(x)}")
114+
)
115+
},
116+
117+
#' @description Print a [ConnectToken()].
118+
#' @param ... Not used.
119+
print = function(...) cli::cat_line(self$format()),
120+
121+
#' @description Returns `TRUE` if the token can be refreshed.
122+
can_refresh = function() TRUE,
123+
124+
#' @description Placeholder implementation of required method. Returns self.
125+
cache = function() self,
126+
127+
#' @description Placeholder implementation of required method. Returns self.
128+
load_from_cache = function() self,
129+
130+
#' @description Placeholder implementation of required method. Not used.
131+
validate = function() {},
132+
133+
#' @description Placeholder implementation of required method. Not used.
134+
revoke = function() {}
135+
), private = list(session = NULL))

man/AuthState-class.Rd

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

man/credentials_app_default.Rd

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

man/credentials_byo_oauth2.Rd

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

man/credentials_connect.Rd

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

0 commit comments

Comments
 (0)