diff --git a/DESCRIPTION b/DESCRIPTION
index 6286393c7..4bf8022f4 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -18,6 +18,7 @@ Imports:
cli (>= 3.0.0),
curl,
glue,
+ lifecycle,
magrittr,
openssl,
R6,
diff --git a/NAMESPACE b/NAMESPACE
index 34b4f4ccd..651beef38 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -109,4 +109,5 @@ export(with_verbosity)
import(R6)
import(rlang)
importFrom(glue,glue)
+importFrom(lifecycle,deprecated)
importFrom(magrittr,"%>%")
diff --git a/NEWS.md b/NEWS.md
index f6f676fc6..3330a7dbf 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -13,6 +13,13 @@
* `oauth_flow_refresh()` now only warns if the `refresh_token` changes, making
it a little easier to use in manual workflows (#186).
+* `oauth_flow_auth_code()` now attempts to detect when you're running in a
+ hosted environment (e.g. Google Collab/Posit Workbench/Posit cloud) and
+ allows users to enter the authorisation code into the console manually (#248).
+
+* `oauth_flow_auth_code()` gains a `redirect_uri` argument rather than deriving
+ this URL automatically from the `host_name` and `port` (#248).
+
# httr2 0.2.3
* New `example_url()` to launch a local server, making tests and examples
diff --git a/R/httr2-package.R b/R/httr2-package.R
index 8b4ab9ab6..952c999dd 100644
--- a/R/httr2-package.R
+++ b/R/httr2-package.R
@@ -2,9 +2,10 @@
"_PACKAGE"
## usethis namespace: start
-#' @import rlang
#' @import R6
+#' @import rlang
#' @importFrom glue glue
+#' @importFrom lifecycle deprecated
## usethis namespace: end
NULL
diff --git a/R/oauth-flow-auth-code.R b/R/oauth-flow-auth-code.R
index 411f8ff55..3edecc33c 100644
--- a/R/oauth-flow-auth-code.R
+++ b/R/oauth-flow-auth-code.R
@@ -56,9 +56,11 @@ req_oauth_auth_code <- function(req, client,
pkce = TRUE,
auth_params = list(),
token_params = list(),
+ type = c("desktop", "web"),
host_name = "localhost",
host_ip = "127.0.0.1",
- port = httpuv::randomPort()
+ port = httpuv::randomPort(),
+ redirect_uri = "http://localhost"
) {
params <- list(
@@ -68,9 +70,11 @@ req_oauth_auth_code <- function(req, client,
pkce = pkce,
auth_params = auth_params,
token_params = token_params,
+ type = type,
host_name = host_name,
host_ip = host_ip,
- port = port
+ port = port,
+ redirect_uri = redirect_uri
)
cache <- cache_choose(client, cache_disk, cache_key)
@@ -85,14 +89,20 @@ req_oauth_auth_code <- function(req, client,
#' Section 4.1. This is the most commonly used OAuth flow where the user is
#' opens a page in their browser, approves the access, and then returns to R.
#'
-#' `oauth_flow_auth_code()` is a high-level wrapper that should
-#' work with APIs that adhere relatively closely to the spec. The remaining
-#' low-level functions can be used to assemble a custom flow for APIs that are
-#' further from the spec:
+#' `oauth_flow_auth_code()` is a high-level wrapper that should work with APIs
+#' that adhere relatively closely to the spec. When possible, it redirects the
+#' browser back to a temporary local webserver to capture the authorization
+#' code. When this is not possible (e.g. when running on a hosted platform
+#' like RStudio Server) set `type = "web"` to instead prompts the user to enter
+#' the code manually instead.
#'
-#' * `oauth_flow_auth_code_url()` generates the url where the user is sent.
-#' * `oauth_flow_auth_code_listen()` starts an webserver that listens for
-#' the response from the resource server.
+#' The remaining low-level functions can be used to assemble a custom flow for
+#' APIs that are further from the spec:
+#'
+#' * `oauth_flow_auth_code_url()` generates the url that should be opened in a
+#' browser.
+#' * `oauth_flow_auth_code_listen()` starts a temporary local webserver that
+#' listens for the response from the resource server.
#' * `oauth_flow_auth_code_parse()` parses the query parameters returned from
#' the server redirect, verifying that the `state` is correct, and returning
#' the authorisation code.
@@ -110,12 +120,20 @@ req_oauth_auth_code <- function(req, client,
#' @param auth_params List containing additional parameters passed to `oauth_flow_auth_code_url()`
#' @param token_params List containing additional parameters passed to the
#' `token_url`.
-#' @param host_name Host name used to generate `redirect_uri`
-#' @param host_ip IP address web server will be bound to.
-#' @param port Port to bind web server to. By default, this uses a random port.
-#' You may need to set it to a fixed port if the API requires that the
+#' @param host_name `r lifecycle::badge("deprecated")` Use `redirect_uri`
+#' instead.
+#' @param host_ip IP address for the temporary webserver used to capture the
+#' authorization code.
+#' @param type Either `desktop` or `web`. Use desktop when running on the
+#' desktop in an environment where you can redirect the user to `localhost`.
+#' Use `web` when running in a hosted web environment.
+#' @param port Port to bind the temporary webserver to. Used only when
+#' `redirect_uri` is `"http(s)://localhost"`. By default, this uses a random
+#' port. You may need to set it to a fixed port if the API requires that the
#' `redirect_uri` specified in the client exactly matches the `redirect_uri`
#' generated by this function.
+#' @param redirect_uri URL to redirect back to after authorization is complete.
+#' Often this must be registered with the API in advance.
#' @returns An [oauth_token].
#' @export
#' @keywords internal
@@ -139,12 +157,38 @@ oauth_flow_auth_code <- function(client,
pkce = TRUE,
auth_params = list(),
token_params = list(),
- host_name = "localhost",
+ host_name = deprecated(),
host_ip = "127.0.0.1",
- port = httpuv::randomPort()
+ type = c("desktop", "web"),
+ port = httpuv::randomPort(),
+ redirect_uri = "http://localhost"
) {
+
+ type <- arg_match(type)
+ if (type == "desktop") {
+ check_installed("httpuv", "desktop OAuth")
+ if (is_hosted_session()) {
+ abort("Only type='web' is supported in the current session")
+ }
+ }
+
oauth_flow_check("authorization code", client, interactive = TRUE)
- check_installed("httpuv")
+
+ # For backwards compatibility, fall back to the original redirect URL
+ # construction.
+ if (lifecycle::is_present(host_name)) {
+ lifecycle::deprecate_warn(
+ when = "0.3.0",
+ what = "oauth_flow_auth_code(host_name)",
+ with = "oauth_flow_auth_code(redirect_uri)"
+ )
+ redirect_uri <- paste0("http://", host_name, ":", port, "/")
+ }
+
+ # Only append a port if we have a bare HTTP(s) localhost redirect.
+ if (grepl("https?://localhost$", redirect_uri)) {
+ redirect_uri <- paste0(redirect_uri, ":", port, "/")
+ }
if (pkce) {
code <- oauth_flow_auth_code_pkce()
@@ -154,26 +198,34 @@ oauth_flow_auth_code <- function(client,
}
state <- base64_url_rand(32)
- redirect_url <- paste0("http://", host_name, ":", port, "/")
- # Redirect user to authorisation url, and listen for result
+ # Redirect user to authorisation url.
user_url <- oauth_flow_auth_code_url(client,
auth_url = auth_url,
- redirect_uri = redirect_url,
+ redirect_uri = redirect_uri,
scope = scope,
state = state,
auth_params = auth_params
)
utils::browseURL(user_url)
- result <- oauth_flow_auth_code_listen(host_ip, port)
- code <- oauth_flow_auth_code_parse(result, state)
+
+ if (type == "desktop") {
+ # Listen on localhost for the result.
+ result <- oauth_flow_auth_code_listen(host_ip, port)
+ code <- oauth_flow_auth_code_parse(result, state)
+ } else {
+ # Allow the user to retrieve the token out of band manually and enter it
+ # into the console. This is what {gargle} terms the "pseudo out-of-band"
+ # flow.
+ code <- oauth_flow_auth_code_read(state)
+ }
# Get access/refresh token from authorisation code
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
oauth_client_get_token(client,
grant_type = "authorization_code",
code = code,
- redirect_uri = redirect_url,
+ redirect_uri = redirect_uri,
!!!token_params
)
}
@@ -182,7 +234,6 @@ oauth_flow_auth_code <- function(client,
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
#' @export
#' @rdname oauth_flow_auth_code
-#' @param redirect_uri URL to which user should be redirected.
#' @param state Random state generated by `oauth_flow_auth_code()`. Used to
#' verify that we're working with an authentication request that we created.
#' (This is an unlikely threat for R packages since the webserver that
@@ -296,3 +347,47 @@ oauth_flow_auth_code_pkce <- function() {
challenge = base64_url_encode(openssl::sha256(charToRaw(verifier)))
)
}
+
+# Try to determine whether we can redirect the user's browser to a server on
+# localhost, which isn't possible if we are running on a hosted platform.
+#
+# Currently this detects RStudio Server, Posit Workbench, and Google Colab. It
+# is based on the strategy pioneered by the {gargle} package.
+is_hosted_session <- function() {
+ if (nzchar(Sys.getenv("COLAB_RELEASE_TAG"))) {
+ return(TRUE)
+ }
+ # If RStudio Server or Posit Workbench is running locally (which is possible,
+ # though unusual), it's not acting as a hosted environment.
+ Sys.getenv("RSTUDIO_PROGRAM_MODE") == "server" &&
+ !grepl("localhost", Sys.getenv("RSTUDIO_HTTP_REFERER"), fixed = TRUE)
+}
+
+oauth_flow_auth_code_read <- function(state) {
+ code <- trimws(read_line("Enter authorization code: "))
+ # We support two options here:
+ #
+ # 1) The original {gargle} style, where the user copy & pastes a
+ # base64-encoded JSON object with both the code and state. This is used on
+ # https://www.tidyverse.org/google-callback/; and
+ #
+ # 2) The full manual approach, where the code and state are entered
+ # independently.
+ result <- tryCatch(
+ jsonlite::fromJSON(rawToChar(openssl::base64_decode(code))),
+ error = function(e) {
+ list(
+ code = code,
+ state = trimws(read_line("Enter state parameter: "))
+ )
+ })
+ if (!identical(result$state, state)) {
+ abort("Authentication failure: state does not match")
+ }
+ result$code
+}
+
+# base::readline() wrapper so we can mock user input during testing.
+read_line <- function(prompt = "") {
+ readline(prompt)
+}
diff --git a/man/figures/lifecycle-archived.svg b/man/figures/lifecycle-archived.svg
index 48f72a6f3..745ab0c78 100644
--- a/man/figures/lifecycle-archived.svg
+++ b/man/figures/lifecycle-archived.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-defunct.svg b/man/figures/lifecycle-defunct.svg
index 01452e5fb..d5c9559ed 100644
--- a/man/figures/lifecycle-defunct.svg
+++ b/man/figures/lifecycle-defunct.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-deprecated.svg b/man/figures/lifecycle-deprecated.svg
index 4baaee01c..b61c57c3f 100644
--- a/man/figures/lifecycle-deprecated.svg
+++ b/man/figures/lifecycle-deprecated.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-experimental.svg b/man/figures/lifecycle-experimental.svg
index d1d060e92..5d88fc2c6 100644
--- a/man/figures/lifecycle-experimental.svg
+++ b/man/figures/lifecycle-experimental.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-maturing.svg b/man/figures/lifecycle-maturing.svg
index df7131014..897370ecf 100644
--- a/man/figures/lifecycle-maturing.svg
+++ b/man/figures/lifecycle-maturing.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-questioning.svg b/man/figures/lifecycle-questioning.svg
index 08ee0c903..7c1721d05 100644
--- a/man/figures/lifecycle-questioning.svg
+++ b/man/figures/lifecycle-questioning.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-soft-deprecated.svg b/man/figures/lifecycle-soft-deprecated.svg
new file mode 100644
index 000000000..9c166ff30
--- /dev/null
+++ b/man/figures/lifecycle-soft-deprecated.svg
@@ -0,0 +1,21 @@
+
diff --git a/man/figures/lifecycle-stable.svg b/man/figures/lifecycle-stable.svg
index e015dc811..9bf21e76b 100644
--- a/man/figures/lifecycle-stable.svg
+++ b/man/figures/lifecycle-stable.svg
@@ -1 +1,29 @@
-
\ No newline at end of file
+
diff --git a/man/figures/lifecycle-superseded.svg b/man/figures/lifecycle-superseded.svg
index 75f24f553..db8d757f7 100644
--- a/man/figures/lifecycle-superseded.svg
+++ b/man/figures/lifecycle-superseded.svg
@@ -1 +1,21 @@
-
\ No newline at end of file
+
diff --git a/man/oauth_flow_auth_code.Rd b/man/oauth_flow_auth_code.Rd
index f6678618d..fb39fc511 100644
--- a/man/oauth_flow_auth_code.Rd
+++ b/man/oauth_flow_auth_code.Rd
@@ -15,9 +15,11 @@ oauth_flow_auth_code(
pkce = TRUE,
auth_params = list(),
token_params = list(),
- host_name = "localhost",
+ host_name = deprecated(),
host_ip = "127.0.0.1",
- port = httpuv::randomPort()
+ type = c("desktop", "web"),
+ port = httpuv::randomPort(),
+ redirect_uri = "http://localhost"
)
oauth_flow_auth_code_url(
@@ -51,16 +53,24 @@ security and should always be used if supported by the server.}
\item{token_params}{List containing additional parameters passed to the
\code{token_url}.}
-\item{host_name}{Host name used to generate \code{redirect_uri}}
+\item{host_name}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Use \code{redirect_uri}
+instead.}
-\item{host_ip}{IP address web server will be bound to.}
+\item{host_ip}{IP address for the temporary webserver used to capture the
+authorization code.}
-\item{port}{Port to bind web server to. By default, this uses a random port.
-You may need to set it to a fixed port if the API requires that the
+\item{type}{Either \code{desktop} or \code{web}. Use desktop when running on the
+desktop in an environment where you can redirect the user to \code{localhost}.
+Use \code{web} when running in a hosted web environment.}
+
+\item{port}{Port to bind the temporary webserver to. Used only when
+\code{redirect_uri} is \code{"http(s)://localhost"}. By default, this uses a random
+port. You may need to set it to a fixed port if the API requires that the
\code{redirect_uri} specified in the client exactly matches the \code{redirect_uri}
generated by this function.}
-\item{redirect_uri}{URL to which user should be redirected.}
+\item{redirect_uri}{URL to redirect back to after authorization is complete.
+Often this must be registered with the API in advance.}
\item{state}{Random state generated by \code{oauth_flow_auth_code()}. Used to
verify that we're working with an authentication request that we created.
@@ -78,14 +88,20 @@ by \href{https://datatracker.ietf.org/doc/html/rfc6749#section-4.1}{rfc6749},
Section 4.1. This is the most commonly used OAuth flow where the user is
opens a page in their browser, approves the access, and then returns to R.
-\code{oauth_flow_auth_code()} is a high-level wrapper that should
-work with APIs that adhere relatively closely to the spec. The remaining
-low-level functions can be used to assemble a custom flow for APIs that are
-further from the spec:
+\code{oauth_flow_auth_code()} is a high-level wrapper that should work with APIs
+that adhere relatively closely to the spec. When possible, it redirects the
+browser back to a temporary local webserver to capture the authorization
+code. When this is not possible (e.g. when running on a hosted platform
+like RStudio Server) set \code{type = "web"} to instead prompts the user to enter
+the code manually instead.
+
+The remaining low-level functions can be used to assemble a custom flow for
+APIs that are further from the spec:
\itemize{
-\item \code{oauth_flow_auth_code_url()} generates the url where the user is sent.
-\item \code{oauth_flow_auth_code_listen()} starts an webserver that listens for
-the response from the resource server.
+\item \code{oauth_flow_auth_code_url()} generates the url that should be opened in a
+browser.
+\item \code{oauth_flow_auth_code_listen()} starts a temporary local webserver that
+listens for the response from the resource server.
\item \code{oauth_flow_auth_code_parse()} parses the query parameters returned from
the server redirect, verifying that the \code{state} is correct, and returning
the authorisation code.
diff --git a/man/req_oauth_auth_code.Rd b/man/req_oauth_auth_code.Rd
index b0f4bdaad..69c88dfaf 100644
--- a/man/req_oauth_auth_code.Rd
+++ b/man/req_oauth_auth_code.Rd
@@ -14,9 +14,11 @@ req_oauth_auth_code(
pkce = TRUE,
auth_params = list(),
token_params = list(),
+ type = c("desktop", "web"),
host_name = "localhost",
host_ip = "127.0.0.1",
- port = httpuv::randomPort()
+ port = httpuv::randomPort(),
+ redirect_uri = "http://localhost"
)
}
\arguments{
@@ -45,14 +47,24 @@ security and should always be used if supported by the server.}
\item{token_params}{List containing additional parameters passed to the
\code{token_url}.}
-\item{host_name}{Host name used to generate \code{redirect_uri}}
+\item{type}{Either \code{desktop} or \code{web}. Use desktop when running on the
+desktop in an environment where you can redirect the user to \code{localhost}.
+Use \code{web} when running in a hosted web environment.}
-\item{host_ip}{IP address web server will be bound to.}
+\item{host_name}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} Use \code{redirect_uri}
+instead.}
-\item{port}{Port to bind web server to. By default, this uses a random port.
-You may need to set it to a fixed port if the API requires that the
+\item{host_ip}{IP address for the temporary webserver used to capture the
+authorization code.}
+
+\item{port}{Port to bind the temporary webserver to. Used only when
+\code{redirect_uri} is \code{"http(s)://localhost"}. By default, this uses a random
+port. You may need to set it to a fixed port if the API requires that the
\code{redirect_uri} specified in the client exactly matches the \code{redirect_uri}
generated by this function.}
+
+\item{redirect_uri}{URL to redirect back to after authorization is complete.
+Often this must be registered with the API in advance.}
}
\value{
A modified HTTP \link{request}.
diff --git a/tests/testthat/_snaps/oauth-flow-auth-code.md b/tests/testthat/_snaps/oauth-flow-auth-code.md
new file mode 100644
index 000000000..4a11bc31f
--- /dev/null
+++ b/tests/testthat/_snaps/oauth-flow-auth-code.md
@@ -0,0 +1,8 @@
+# desktop style can't run in hosted environment
+
+ Code
+ oauth_flow_auth_code(client, "http://example.com", type = "desktop")
+ Condition
+ Error in `oauth_flow_auth_code()`:
+ ! Only type='web' is supported in the current session
+
diff --git a/tests/testthat/test-oauth-flow-auth-code.R b/tests/testthat/test-oauth-flow-auth-code.R
new file mode 100644
index 000000000..0c130f12e
--- /dev/null
+++ b/tests/testthat/test-oauth-flow-auth-code.R
@@ -0,0 +1,48 @@
+test_that("desktop style can't run in hosted environment", {
+ client <- oauth_client("abc", "http://example.com")
+
+ withr::local_envvar("RSTUDIO_PROGRAM_MODE" = "server")
+ expect_snapshot(
+ oauth_flow_auth_code(client, "http://example.com", type = "desktop"),
+ error = TRUE
+ )
+})
+
+test_that("so-called 'hosted' sessions are detected correctly", {
+ withr::with_envvar(c("RSTUDIO_PROGRAM_MODE" = "server"), {
+ expect_true(is_hosted_session())
+ })
+ # Emulate running outside RStudio Server if we happen to be running our tests
+ # under it.
+ withr::with_envvar(c("RSTUDIO_PROGRAM_MODE" = NA), {
+ expect_false(is_hosted_session())
+ })
+})
+
+test_that("JSON-encoded authorisation codes can be input manually", {
+ state <- base64_url_rand(32)
+ input <- list(state = state, code = "abc123")
+ encoded <- openssl::base64_encode(jsonlite::toJSON(input))
+ local_mocked_bindings(
+ read_line = function(prompt = "") encoded
+ )
+ expect_equal(oauth_flow_auth_code_read(state), "abc123")
+ expect_error(oauth_flow_auth_code_read("invalid"), "state does not match")
+})
+
+test_that("bare authorisation codes can be input manually", {
+ state <- base64_url_rand(32)
+ sent_code <- FALSE
+ local_mocked_bindings(
+ read_line = function(prompt = "") {
+ if (sent_code) {
+ state
+ } else {
+ sent_code <<- TRUE
+ "zyx987"
+ }
+ }
+ )
+ expect_equal(oauth_flow_auth_code_read(state), "zyx987")
+ expect_error(oauth_flow_auth_code_read("invalid"), "state does not match")
+})