Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft for adding OAuth support to shiny #518

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

thohan88
Copy link

@thohan88 thohan88 commented Aug 25, 2024

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 #47 and attempts to bring support for OAuth 2.0 apps to shiny. It builds upon the cookie and routing approach developed in r-lib/gargle#157 and extends this to:

  • Support multiple OAuth 2.0 providers for a single application
  • Include pre-configured OAuth clients for popular providers (e.g., GitHub, Google, Microsoft).
  • Handles OpenID providers with signature verification
  • Integrate with other httr2 functionality (e.g. token objects)

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

  • Use http://127.0.0.1 instead of http://localhost as the redirect URL. Cookies set at localhost won’t persist when using 127.0.0.1, causing the OAuth flow to fail.
  • If using RStudio, set options(shiny.launch.browser = TRUE) to avoid issues with the built-in browser, which does not support external redirects and OAuth cookies.

Shinyapps.io

Shinyapps.io works well with non-OpenID providers (e.g., GitHub, Spotify). However, OpenID providers like Google and Microsoft may cause issues due to how Shinyapps.io handles redirection, potentially triggering a loading screen that replays the OAuth flow with the same authorization code causing it to fail.

Cloud Deployment

Shiny OAuth apps can be deployed as Docker containers, even on serverless platforms like Azure Container Apps and Google Cloud Run. Ensure you set the HTTR2_OAUTH_APP_URL environment variable to guarantee the correct server URL is inferred.

Shiny Server

Shiny Server is not compatible with Shiny OAuth apps because it strips cookies.

Further Work

  • Documentation: Requires improvement, including more examples.
  • QA Auth Setup : Ensure there is no way to bypass without a valid token.
  • Inferring App URL : Improve logic to reduce need for HTTR2_OAUTH_APP_URL.
  • Token Refreshing: Should token refreshing be allowed?
  • Support req_oauth_auth_code_shiny: Finalize API before adding this.
  • Config and client setup and validation: Potential for lots of improvements. Maybe it should be oauth_shiny_config() instead of passing separate arguments to oauth_shiny_app.
  • Persisting State: Explore server-side cookie bookmarking.
  • Replace sodium?

Closing Remarks

I hope this serves as a sufficient draft for discussing how OAuth support could be integrated in shiny. I think this functionality could make it much easier for others to integrate OAuth apps. It feels like httr2 could be the right place for it, but happy to discuss this. This is my first PR for a public R package, apologies in advance if I have made errors or overlooked standards.👍

@hadley
Copy link
Member

hadley commented Aug 29, 2024

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
Copy link
Author

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.

@jrosell
Copy link

jrosell commented Sep 17, 2024

I don't know if it's appropiate to say this, but could we make this someway works for plumber or httpuv usage too? Futhermore, I don't know if it would be better to have a specific package for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants