r-lib / httr2

Make HTTP requests and process their responses. A modern reimagining of httr.
https://httr2.r-lib.org
Other
235 stars 56 forks source link

Draft for adding OAuth support to shiny #518

Open thohan88 opened 2 weeks ago

thohan88 commented 2 weeks ago

Info: This is a draft for discussion purposes. It's not a polished PR and currently includes minimal error handling and documentation. It may be big enough to warrant a separate package, but it may also be so tighly coupled with httr2 that it makes sense for it to be integrated.

Summary

This PR addresses https://github.com/r-lib/httr2/issues/47 and attempts to bring support for OAuth 2.0 apps to shiny. It builds upon the cookie and routing approach developed in https://github.com/r-lib/gargle/pull/157 and extends this to:

This PR primarily addresses two key scenarios:

  1. Gating Access: Enforcing authentication to a Shiny application, using a login UI and designated OAuth providers.
  2. Token Retrieval: Retrieving OAuth tokens on behalf of users to interact with external APIs, regardless of whether the app itself requires authentication.

A more detailed guide for getting started is included in vignettes/articles/shiny.Rmd .

Demo

You can run this locally or view an example application on shinyapps.io or a deployed version on Google Cloud Run. The demo application runs oauth_shiny_app_example() and stores no user information. Here is a preview of what to expect:

oauth_shiny_example

OAuth 2.0 Authentication for Apps

To enforce login within a Shiny application, use oauth_shiny_app() with a configuration of OAuth clients:

options(shiny.port = 1410) 
options(shiny.launch.browser = TRUE)
Sys.setenv(HTTR2_OAUTH_PASSPHRASE = "MySecurePassPhrase")
Sys.setenv(HTTR2_OAUTH_REDIRECT_URL = "http://127.0.0.1:1410")

config <- oauth_shiny_client_config(
  oauth_shiny_client_github(auth_provider = TRUE),
  oauth_shiny_client_google(auth_provider = TRUE),
  oauth_shiny_client_spotify(auth_provider = TRUE),
  oauth_shiny_client_microsoft(auth_provider = TRUE)
)

shinyApp(...) |>
  oauth_shiny_app(config, dark_mode = FALSE)

Standard clients (e.g. oauth_client_github()) resolve client IDs and secrets using environment variables (OAUTH_GITHUB_ID and OAUTH_GITHUB_SECRET). This setup will present a sign-in UI with login buttons (based on Google Material) for clients marked as auth_provider = TRUE. Buttons link to client login endpoints (e.g. login/github) which triggers the OAuth flow.

shiny_app_login

Upon loading, the application checks for a signed cookie containing standard claims (at minimum identifier and provider) , which is set after successful authentication for a client with auth_provider = TRUE. These claims can be retrieved from the server side in Shiny to customize the user interface:

claims <- oauth_shiny_get_app_token()

To disable authentication, pass require_auth = FALSE:

shinyApp(...) |>
  oauth_shiny_app(config, require_auth = FALSE)

Fetching access tokens

By default, access tokens retrieved after completing the OAuth flow are not stored (access_token_validity = 0). Client access tokens can be stored as encrypted cookies with a max-age equal to access_token_validity. These tokens can be retrieved as oauth_token objects from the server side using:

github_token <- oauth_shiny_get_access_token(config$github)

Here is an example which requests user information from Github in a public app :

library(httr2)
library(shiny)

config <- oauth_shiny_client_config(
  oauth_shiny_client_github(
    access_token_validity = 3600
  )
)

ui <- fluidPage(
  h1("Publicly available Shiny App"),
  uiOutput("button"),
  p("Token:", textOutput("token", inline = TRUE)),
  p("User info:", verbatimTextOutput("userinfo"))
)

server <- function(input, output, session) {
  token <- reactive(oauth_shiny_get_access_token(config$github))
  logged_in <- reactive(!is.null(token()))
  # Render a login or logout button depending on whether the user is logged in
  output$button <- renderUI({
    path  <- if (logged_in()) "logout/github" else "login/github"
    title <- if (logged_in()) "Log out of Github" else "Log in to Github"
    httr2:::oauth_shiny_ui_button_github(path, title)
  })
  # Print token
  output$token <- renderText(token()[["access_token"]])
  # Print userinfo from Github
  output$userinfo <- renderPrint({
    req(token())
    request("https://api.github.com/user") |>
      req_auth_bearer_token(token()$access_token) |>
      req_perform() |>
      resp_body_json() |>
      str()
  })
}

shinyApp(ui, server) |>
  oauth_shiny_app(config, require_auth = FALSE)

Logging out

Redirecting users to logout clears both app cookies and all client access token cookies. Redirecting to logout/{client} will only clear a single client's access token cookies.

Example application

An example application (same as demo) is included to facilitate debugging client configurations and token retrieval:

library(httr2)

config <- oauth_shiny_client_config(
  oauth_shiny_client_github(
    auth_provider = TRUE,
    access_token_validity = 3600
  ),
  oauth_shiny_client_google(
    auth_provider = TRUE,
    access_token_validity = 3600
  ),
  oauth_shiny_client_spotify(
    auth_provider = TRUE,
    access_token_validity = 3600
  )
)

oauth_shiny_app_example(config)

OAuth Shiny Client

This PR introduces oauth_shiny_client(), similar to oauth_client, but with additional information necessary to complete the authorization code flow. Standardized clients for GitHub, Google, Microsoft, and Spotify are included, but custom clients can be added easily, e.g. for Strava:

strava <- oauth_shiny_client(
  name = "strava",
  id = Sys.getenv("OAUTH_STRAVA_CLIENT_ID"),
  secret = Sys.getenv("OAUTH_STRAVA_CLIENT_SECRET"),
  auth_url = "https://www.strava.com/oauth/authorize",
  token_url = "https://www.strava.com/api/v3/oauth/token",
  pkce = FALSE,
  scope = "read",
  auth_provider = TRUE,
  login_button = oauth_shiny_ui_button(
    path = "login/strava", 
    title = "Sign in with Strava",
    logo = "images/strava.svg"
  )
)

For OAuth 2.0 applications compliant with the OpenID specification, it is enough to pass the open_issuer_url and scope and optionally the claims to retrieve:

google <- oauth_shiny_client(
    name = "google",
    id = Sys.getenv("OAUTH_GOOGLE_CLIENT_ID"),
    secret = Sys.getenv("OAUTH_GOOGLE_CLIENT_SECRET"),
    openid_issuer_url = "https://accounts.google.com/",
    openid_claims = c("name", "email", "aud", "sub")
    scope = "openid profile email",
    login_button = oauth_shiny_ui_button_google(),
    login_button_dark = oauth_shiny_ui_button_google_dark(),
)

This will automatically resolve the auth_url and token_url endpoints and verify the signature of retrieved access tokens using public JSON Web Keys (JWK).

Limitations

State Loss During Redirection

Currently, Shiny OAuth apps lose state during the OAuth redirection process. This could potentially be addressed by setting a server-side bookmarked state as a secure cookie, but this is not something I have given much thought.

Local Development

hadley commented 2 weeks ago

Thanks for working this! Obviously it's a big PR so it'll take me + @jcheng5 a little while to get our heads around this, but we really appreciate you working on it!

thohan88 commented 2 weeks ago

Thanks for working this! Obviously it's a big PR so it'll take me + @jcheng5 a little while to get our heads around this, but we really appreciate you working on it!

Thanks, totally understand! I figured it made sense to start with a fully working example to get the discussion going on how the API could work. There's still plenty of room for improvement, but I felt it was at a point where some feedback would be really helpful.