Skip to content

Commit 7f603ff

Browse files
authored
Improve control over redirects during auth code flows (#248)
When sheparding a user through an interactive authorisation code flow to access APIs protected by OAuth2, the oauth_flow_auth_code() function runs a temporary webserver on localhost to capture the code. This is a very common technique for OAuth client libraries. Unfortunately, this doesn't work in environments where localhost is probably inaccessible to users -- which is normally the case on RStudio Server and Posit Workbench. Previously the oauth_flow_auth_code() function would hang indefinitely in these environments. This commit fixes this failure mode by attempting to detect when this temporary localhost server will actually work and only runs one if it will. Of course, this doesn't resolve the issue for users that can't run a localhost server -- so in this situation, we now allow users to manually paste the authorisation code into the console from... somewhere. In the likely event that they don't want to be redirected to localhost for this step, this commit also fulfills the longstanding request for control over the redirect URL. The combination of a manual copy & paste step with a custom redirect URL is what the gargle package terms the "pseudo out-of-band" flow, and the design used here is largely based on that package. Because of this compatibility, users could register the existing public page at https://www.tidyverse.org/google-callback/ with their OAuth provider, host their own clone, or create a variant. Control over the redirect URL is backwards-compatible but does require making the 'host_name' parameter deprecated, unfortunately. Unit tests for new functions are included, as are various updates to the documentation. Fixes #165
1 parent 5b6dbfa commit 7f603ff

18 files changed

+429
-51
lines changed

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Imports:
1919
cli (>= 3.0.0),
2020
curl (>= 5.0.2),
2121
glue,
22+
lifecycle,
2223
magrittr,
2324
openssl,
2425
R6,

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,5 @@ export(with_verbosity)
114114
import(R6)
115115
import(rlang)
116116
importFrom(glue,glue)
117+
importFrom(lifecycle,deprecated)
117118
importFrom(magrittr,"%>%")

NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
* `oauth_flow_refresh()` now only warns if the `refresh_token` changes, making
6565
it a little easier to use in manual workflows (#186).
6666

67+
* `oauth_flow_auth_code()` now attempts to detect when you're running in a
68+
hosted environment (e.g. Google Collab/Posit Workbench/Posit cloud) and
69+
allows users to enter the authorisation code into the console manually (#248).
70+
71+
* `oauth_flow_auth_code()` gains a `redirect_uri` argument rather than deriving
72+
this URL automatically from the `host_name` and `port` (#248).
73+
6774
# httr2 0.2.3
6875

6976
* New `example_url()` to launch a local server, making tests and examples

R/httr2-package.R

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
"_PACKAGE"
33

44
## usethis namespace: start
5-
#' @import rlang
65
#' @import R6
6+
#' @import rlang
77
#' @importFrom glue glue
8+
#' @importFrom lifecycle deprecated
89
## usethis namespace: end
910
NULL
1011

R/oauth-flow-auth-code.R

Lines changed: 118 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ req_oauth_auth_code <- function(req, client,
5656
pkce = TRUE,
5757
auth_params = list(),
5858
token_params = list(),
59+
type = c("desktop", "web"),
5960
host_name = "localhost",
6061
host_ip = "127.0.0.1",
61-
port = httpuv::randomPort()
62+
port = httpuv::randomPort(),
63+
redirect_uri = "http://localhost"
6264
) {
6365

6466
params <- list(
@@ -68,9 +70,11 @@ req_oauth_auth_code <- function(req, client,
6870
pkce = pkce,
6971
auth_params = auth_params,
7072
token_params = token_params,
73+
type = type,
7174
host_name = host_name,
7275
host_ip = host_ip,
73-
port = port
76+
port = port,
77+
redirect_uri = redirect_uri
7478
)
7579

7680
cache <- cache_choose(client, cache_disk, cache_key)
@@ -85,14 +89,20 @@ req_oauth_auth_code <- function(req, client,
8589
#' Section 4.1. This is the most commonly used OAuth flow where the user is
8690
#' opens a page in their browser, approves the access, and then returns to R.
8791
#'
88-
#' `oauth_flow_auth_code()` is a high-level wrapper that should
89-
#' work with APIs that adhere relatively closely to the spec. The remaining
90-
#' low-level functions can be used to assemble a custom flow for APIs that are
91-
#' further from the spec:
92+
#' `oauth_flow_auth_code()` is a high-level wrapper that should work with APIs
93+
#' that adhere relatively closely to the spec. When possible, it redirects the
94+
#' browser back to a temporary local webserver to capture the authorization
95+
#' code. When this is not possible (e.g. when running on a hosted platform
96+
#' like RStudio Server) set `type = "web"` to instead prompts the user to enter
97+
#' the code manually instead.
9298
#'
93-
#' * `oauth_flow_auth_code_url()` generates the url where the user is sent.
94-
#' * `oauth_flow_auth_code_listen()` starts an webserver that listens for
95-
#' the response from the resource server.
99+
#' The remaining low-level functions can be used to assemble a custom flow for
100+
#' APIs that are further from the spec:
101+
#'
102+
#' * `oauth_flow_auth_code_url()` generates the url that should be opened in a
103+
#' browser.
104+
#' * `oauth_flow_auth_code_listen()` starts a temporary local webserver that
105+
#' listens for the response from the resource server.
96106
#' * `oauth_flow_auth_code_parse()` parses the query parameters returned from
97107
#' the server redirect, verifying that the `state` is correct, and returning
98108
#' the authorisation code.
@@ -110,12 +120,20 @@ req_oauth_auth_code <- function(req, client,
110120
#' @param auth_params List containing additional parameters passed to `oauth_flow_auth_code_url()`
111121
#' @param token_params List containing additional parameters passed to the
112122
#' `token_url`.
113-
#' @param host_name Host name used to generate `redirect_uri`
114-
#' @param host_ip IP address web server will be bound to.
115-
#' @param port Port to bind web server to. By default, this uses a random port.
116-
#' You may need to set it to a fixed port if the API requires that the
123+
#' @param host_name `r lifecycle::badge("deprecated")` Use `redirect_uri`
124+
#' instead.
125+
#' @param host_ip IP address for the temporary webserver used to capture the
126+
#' authorization code.
127+
#' @param type Either `desktop` or `web`. Use desktop when running on the
128+
#' desktop in an environment where you can redirect the user to `localhost`.
129+
#' Use `web` when running in a hosted web environment.
130+
#' @param port Port to bind the temporary webserver to. Used only when
131+
#' `redirect_uri` is `"http(s)://localhost"`. By default, this uses a random
132+
#' port. You may need to set it to a fixed port if the API requires that the
117133
#' `redirect_uri` specified in the client exactly matches the `redirect_uri`
118134
#' generated by this function.
135+
#' @param redirect_uri URL to redirect back to after authorization is complete.
136+
#' Often this must be registered with the API in advance.
119137
#' @returns An [oauth_token].
120138
#' @export
121139
#' @keywords internal
@@ -139,12 +157,38 @@ oauth_flow_auth_code <- function(client,
139157
pkce = TRUE,
140158
auth_params = list(),
141159
token_params = list(),
142-
host_name = "localhost",
160+
host_name = deprecated(),
143161
host_ip = "127.0.0.1",
144-
port = httpuv::randomPort()
162+
type = c("desktop", "web"),
163+
port = httpuv::randomPort(),
164+
redirect_uri = "http://localhost"
145165
) {
166+
167+
type <- arg_match(type)
168+
if (type == "desktop") {
169+
check_installed("httpuv", "desktop OAuth")
170+
if (is_hosted_session()) {
171+
abort("Only type='web' is supported in the current session")
172+
}
173+
}
174+
146175
oauth_flow_check("authorization code", client, interactive = TRUE)
147-
check_installed("httpuv")
176+
177+
# For backwards compatibility, fall back to the original redirect URL
178+
# construction.
179+
if (lifecycle::is_present(host_name)) {
180+
lifecycle::deprecate_warn(
181+
when = "0.3.0",
182+
what = "oauth_flow_auth_code(host_name)",
183+
with = "oauth_flow_auth_code(redirect_uri)"
184+
)
185+
redirect_uri <- paste0("http://", host_name, ":", port, "/")
186+
}
187+
188+
# Only append a port if we have a bare HTTP(s) localhost redirect.
189+
if (grepl("https?://localhost$", redirect_uri)) {
190+
redirect_uri <- paste0(redirect_uri, ":", port, "/")
191+
}
148192

149193
if (pkce) {
150194
code <- oauth_flow_auth_code_pkce()
@@ -154,26 +198,34 @@ oauth_flow_auth_code <- function(client,
154198
}
155199

156200
state <- base64_url_rand(32)
157-
redirect_url <- paste0("http://", host_name, ":", port, "/")
158201

159-
# Redirect user to authorisation url, and listen for result
202+
# Redirect user to authorisation url.
160203
user_url <- oauth_flow_auth_code_url(client,
161204
auth_url = auth_url,
162-
redirect_uri = redirect_url,
205+
redirect_uri = redirect_uri,
163206
scope = scope,
164207
state = state,
165208
auth_params = auth_params
166209
)
167210
utils::browseURL(user_url)
168-
result <- oauth_flow_auth_code_listen(host_ip, port)
169-
code <- oauth_flow_auth_code_parse(result, state)
211+
212+
if (type == "desktop") {
213+
# Listen on localhost for the result.
214+
result <- oauth_flow_auth_code_listen(host_ip, port)
215+
code <- oauth_flow_auth_code_parse(result, state)
216+
} else {
217+
# Allow the user to retrieve the token out of band manually and enter it
218+
# into the console. This is what {gargle} terms the "pseudo out-of-band"
219+
# flow.
220+
code <- oauth_flow_auth_code_read(state)
221+
}
170222

171223
# Get access/refresh token from authorisation code
172224
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
173225
oauth_client_get_token(client,
174226
grant_type = "authorization_code",
175227
code = code,
176-
redirect_uri = redirect_url,
228+
redirect_uri = redirect_uri,
177229
!!!token_params
178230
)
179231
}
@@ -182,7 +234,6 @@ oauth_flow_auth_code <- function(client,
182234
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
183235
#' @export
184236
#' @rdname oauth_flow_auth_code
185-
#' @param redirect_uri URL to which user should be redirected.
186237
#' @param state Random state generated by `oauth_flow_auth_code()`. Used to
187238
#' verify that we're working with an authentication request that we created.
188239
#' (This is an unlikely threat for R packages since the webserver that
@@ -296,3 +347,47 @@ oauth_flow_auth_code_pkce <- function() {
296347
challenge = base64_url_encode(openssl::sha256(charToRaw(verifier)))
297348
)
298349
}
350+
351+
# Try to determine whether we can redirect the user's browser to a server on
352+
# localhost, which isn't possible if we are running on a hosted platform.
353+
#
354+
# Currently this detects RStudio Server, Posit Workbench, and Google Colab. It
355+
# is based on the strategy pioneered by the {gargle} package.
356+
is_hosted_session <- function() {
357+
if (nzchar(Sys.getenv("COLAB_RELEASE_TAG"))) {
358+
return(TRUE)
359+
}
360+
# If RStudio Server or Posit Workbench is running locally (which is possible,
361+
# though unusual), it's not acting as a hosted environment.
362+
Sys.getenv("RSTUDIO_PROGRAM_MODE") == "server" &&
363+
!grepl("localhost", Sys.getenv("RSTUDIO_HTTP_REFERER"), fixed = TRUE)
364+
}
365+
366+
oauth_flow_auth_code_read <- function(state) {
367+
code <- trimws(read_line("Enter authorization code: "))
368+
# We support two options here:
369+
#
370+
# 1) The original {gargle} style, where the user copy & pastes a
371+
# base64-encoded JSON object with both the code and state. This is used on
372+
# https://www.tidyverse.org/google-callback/; and
373+
#
374+
# 2) The full manual approach, where the code and state are entered
375+
# independently.
376+
result <- tryCatch(
377+
jsonlite::fromJSON(rawToChar(openssl::base64_decode(code))),
378+
error = function(e) {
379+
list(
380+
code = code,
381+
state = trimws(read_line("Enter state parameter: "))
382+
)
383+
})
384+
if (!identical(result$state, state)) {
385+
abort("Authentication failure: state does not match")
386+
}
387+
result$code
388+
}
389+
390+
# base::readline() wrapper so we can mock user input during testing.
391+
read_line <- function(prompt = "") {
392+
readline(prompt)
393+
}

man/figures/lifecycle-archived.svg

Lines changed: 21 additions & 1 deletion
Loading

man/figures/lifecycle-defunct.svg

Lines changed: 21 additions & 1 deletion
Loading

man/figures/lifecycle-deprecated.svg

Lines changed: 21 additions & 1 deletion
Loading
Lines changed: 21 additions & 1 deletion
Loading

0 commit comments

Comments
 (0)