Skip to content

Commit b1c1f8d

Browse files
committed
Add support for viewer-based credentials on Posit Connect.
This commit brings support for Posit Connect's "viewer-based credentials" feature [0] to `gh`. Checks for viewer-based credentials are designed to fall back gracefully to existing authentication methods. This is intended to allow users to -- for example -- develop and test a Shiny app that uses GitHub credentials in desktop Positron/RStudio or Posit Workbench and deploy it with no code changes to Connect. Most of the actual work is outsourced to a new shared package, `connectcreds` [1]. 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 <[email protected]>
1 parent f391c2b commit b1c1f8d

File tree

5 files changed

+153
-10
lines changed

5 files changed

+153
-10
lines changed

DESCRIPTION

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Imports:
2323
lifecycle,
2424
rlang (>= 1.0.0)
2525
Suggests:
26+
connectcreds,
2627
covr,
2728
knitr,
2829
rmarkdown,
@@ -39,4 +40,5 @@ Language: en-US
3940
Roxygen: list(markdown = TRUE)
4041
RoxygenNote: 7.3.2
4142
Remotes:
43+
posit-dev/connectcreds,
4244
r-lib/httr2

NEWS.md

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* `gh()` now uses a cache provided by httr2. This cache lives in `tools::R_user_dir("gh", "cache")`, maxes out at 100 MB, and can be disabled by setting `options(gh_cache = FALSE)` (#203).
55
* Removes usage of mockery (@tanho63, #197)
66

7+
* `gh_token()` can now pick up on GitHub credentials from the current Shiny
8+
session when running on Posit Connect (@atheriel, #217).
9+
710
# gh 1.4.1
811

912
* `gh_next()`, `gh_prev()`, `gh_first()` and `gh_last()`

R/gh_token.R

+39-10
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,18 @@
4646
gh_token <- function(api_url = NULL) {
4747
api_url <- api_url %||% default_api_url()
4848
stopifnot(is.character(api_url), length(api_url) == 1)
49+
host_url <- get_hosturl(api_url)
50+
# Session credentials take precedence over static credentials.
51+
session <- get_connect_session()
52+
if (!is.null(session)) {
53+
check_installed("connectcreds", "for viewer-based authentication")
54+
if (connectcreds::has_viewer_token(session, host_url)) {
55+
token <- connectcreds::connect_viewer_token(session, host_url)
56+
return(gh_pat(token))
57+
}
58+
}
4959
token <- tryCatch(
50-
gitcreds::gitcreds_get(get_hosturl(api_url)),
60+
gitcreds::gitcreds_get(host_url),
5161
error = function(e) NULL
5262
)
5363
gh_pat(token$password %||% "")
@@ -56,15 +66,7 @@ gh_token <- function(api_url = NULL) {
5666
#' @export
5767
#' @rdname gh_token
5868
gh_token_exists <- function(api_url = NULL) {
59-
api_url <- api_url %||% default_api_url()
60-
tryCatch(
61-
{
62-
token <- gitcreds::gitcreds_get(get_hosturl(api_url))
63-
gh_pat(token$password)
64-
TRUE
65-
} ,
66-
error = function(e) FALSE
67-
)
69+
tryCatch(nzchar(gh_token(api_url)), error = function(e) FALSE)
6870
}
6971

7072
gh_auth <- function(token) {
@@ -142,3 +144,30 @@ obfuscate <- function(x, first = 4, last = 4) {
142144
substr(x, start = nchar(x) - last + 1, stop = nchar(x))
143145
)
144146
}
147+
148+
get_connect_session <- function() {
149+
if (!identical(Sys.getenv("RSTUDIO_PRODUCT"), "CONNECT")) {
150+
return(NULL)
151+
}
152+
if (!isNamespaceLoaded("shiny")) {
153+
return(NULL)
154+
}
155+
if (is_installed("connectcreds")) {
156+
# Avoid taking a Suggests dependency on Shiny, which is otherwise
157+
# irrelevant to gh.
158+
f <- get("getDefaultReactiveDomain", envir = asNamespace("shiny"))
159+
return(f())
160+
}
161+
cli::cli_inform(
162+
c(
163+
"Viewer-based credentials for GitHub require the {.pkg connectcreds} \
164+
package, but it is not installed.",
165+
"i" = "Redeploy with {.pkg connectcreds} as a dependency if you wish to \
166+
use viewer-based credentials. The most common way to do this is \
167+
to add {.code library(connectcreds)} to your {.file app.R} file."
168+
),
169+
.frequency = "once",
170+
.frequency_id = "connectcreds_missing"
171+
)
172+
NULL
173+
}

tests/testthat/_snaps/gh_token.md

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# missing viewer credentials can be debugged
2+
3+
Code
4+
. <- gh_token()
5+
Message
6+
No viewer-based credentials found.
7+
Caused by error in `connect_viewer_token()`:
8+
! Viewer-based credentials are not supported by this version of Connect.
9+
10+
# token exchange requests to Connect look correct
11+
12+
Code
13+
list(url = req$url, headers = req$headers, body = req$body$data)
14+
Output
15+
$url
16+
[1] "localhost:3030/__api__/v1/oauth/integrations/credentials"
17+
18+
$headers
19+
$headers$Authorization
20+
[1] "Key key"
21+
22+
$headers$Accept
23+
[1] "application/json"
24+
25+
attr(,"redact")
26+
[1] "Authorization"
27+
28+
$body
29+
$body$grant_type
30+
[1] "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"
31+
32+
$body$subject_token
33+
[1] "user-token"
34+
35+
$body$subject_token_type
36+
[1] "urn%3Aposit%3Aconnect%3Auser-session-token"
37+
38+
$body$resource
39+
[1] "https%3A%2F%2Fgithub.com"
40+
41+
42+
43+
---
44+
45+
Code
46+
list(url = req$url, headers = req$headers, body = req$body$data)
47+
Output
48+
$url
49+
[1] "localhost:3030/__api__/v1/oauth/integrations/credentials"
50+
51+
$headers
52+
$headers$Authorization
53+
[1] "Key key"
54+
55+
$headers$Accept
56+
[1] "application/json"
57+
58+
attr(,"redact")
59+
[1] "Authorization"
60+
61+
$body
62+
$body$grant_type
63+
[1] "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"
64+
65+
$body$subject_token
66+
[1] "user-token"
67+
68+
$body$subject_token_type
69+
[1] "urn%3Aposit%3Aconnect%3Auser-session-token"
70+
71+
$body$resource
72+
[1] "https%3A%2F%2Fgithub.com"
73+
74+
75+

tests/testthat/test-gh_token.R

+34
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,37 @@ test_that("get_apiurl() works", {
163163
expect_equal(get_apiurl("https://github.acme.com/OWNER/REPO"), x)
164164
expect_equal(get_apiurl("https://github.acme.com/api/v3"), x)
165165
})
166+
167+
test_that("missing viewer credentials can be debugged", {
168+
# Mock a Connect environment that *does not* support viewer-based credentials.
169+
withr::local_envvar(RSTUDIO_PRODUCT = "CONNECT")
170+
local_mocked_bindings(get_connect_session = function() {
171+
structure(list(request = list()), class = "ShinySession")
172+
})
173+
local_options(connectcreds_debug = TRUE)
174+
expect_snapshot(. <- gh_token())
175+
})
176+
177+
test_that("token exchange requests to Connect look correct", {
178+
# Mock a Connect environment that supports viewer-based credentials.
179+
withr::local_envvar(
180+
RSTUDIO_PRODUCT = "CONNECT",
181+
CONNECT_SERVER = "localhost:3030",
182+
CONNECT_API_KEY = "key"
183+
)
184+
local_mocked_bindings(get_connect_session = function() {
185+
structure(
186+
list(request = list(HTTP_POSIT_CONNECT_USER_SESSION_TOKEN = "user-token")),
187+
class = "ShinySession"
188+
)
189+
})
190+
token <- strrep("a", 40)
191+
httr2::local_mocked_responses(function(req) {
192+
# Snapshot relevant fields of the outgoing request.
193+
expect_snapshot(
194+
list(url = req$url, headers = req$headers, body = req$body$data)
195+
)
196+
httr2::response_json(body = list(access_token = token))
197+
})
198+
expect_equal(gh_token(), gh_pat(token))
199+
})

0 commit comments

Comments
 (0)