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.

@atheriel
Copy link
Contributor

atheriel commented Oct 9, 2024

Hi, I'm one of the people who worked on viewer-based OAuth credentials in Posit Connect, which does what I think you're aiming for here. I can't speak to all of the technical choices you've made here, but I thought I could give a sense of some of the problems we saw that led us to solve this in the platform rather than in individual Shiny apps, as you've done here:

  • The OAuth authorization code flow is inherently stateful (doubly so with PKCE), and storing backend state in Shiny apps that persist across sessions is tricky, especially when you have multiple processes (though that wouldn't be the case in your deployment target, which seems to be a single container). Most non-Shiny applications solve this by using an out-of-process cache (like Redis) or a database. I think using cookies here (as I think you've done) is "fine", though it requires a lot of knowledge about the deployment URL to do securely -- maybe browser local storage would be a better choice.

  • Handling redirects within Shiny itself means that you close the websocket during the login flow and lose any in-memory data for your session in the process. I think this leads to pretty surprising behaviour and violates how users and Shiny app authors expect Shiny to work.

  • The lack of a real way to store backend state means that storing refresh tokens is out. I'm pretty uncomfortable with the security implications of storing refresh tokens in cookies (as I think you've done here?), it would seem to violate the OAuth standard's definition of a "confidential client". And without refresh tokens, the user experience is pretty poor, since you'd constantly have to log in again. That might be fine for providers like GitHub that give you an access token that lasts 8 hours, but it's extremely annoying -- from experience -- with a provider like Snowflake where tokens last only ten minutes.

Some of it also reflected patterns we see in organizations, which might not be applicable to pure open-source power users:

  • The need to preregister redirect URLs with the provider in advance enforces a one-OAuth-client-per-Shiny-app model if Shiny is handling the redirects (not in every case, some providers allow multiple redirect URLs). This is fine if you have one or two apps that need OAuth. It's pretty uncomfortable if you deploy 10 dashboards that all need a different one of those Google JSON files.

  • Shiny app authors rarely had the authority or ability to register an OAuth client themselves. This meant it could take weeks of back-and-forth with IT to get a client approved for a given redirect URL, if it was even possible at all. IT was often pretty uncomfortable with Shiny app authors managing sensitive OAuth client secrets themselves, too. Anecdotally, IT folks seem pretty happy that OAuth clients are managed by a Connect admin in our case, rather than by individual app authors.

Finally, although it might be irrelevant here: we wanted something that worked for Python and Quarto, too. (Plus non-Shiny interactive R content, although it's rare to see that in practice.)

@thohan88
Copy link
Author

thohan88 commented Oct 9, 2024

Thanks for the input, @atheriel.

The OAuth authorization code flow is inherently stateful (doubly so with PKCE), and storing backend state in Shiny apps that persist across sessions is tricky, especially when you have multiple processes (though that wouldn't be the case in your deployment target, which seems to be a single container). Most non-Shiny applications solve this by using an out-of-process cache (like Redis) or a database. I think using cookies here (as I think you've done) is "fine", though it requires a lot of knowledge about the deployment URL to do securely -- maybe browser local storage would be a better choice.

Are you referring to setting the state value in the OAuth flow? It's pretty standard to set this as a cookie (httponly, secure, samesite=lax), see e.g. authlib, next-auth and mod_auth_openidc. I would be interested to hear more about why local browser storage would be a better choice.

Handling redirects within Shiny itself means that you close the websocket during the login flow and lose any in-memory data for your session in the process. I think this leads to pretty surprising behaviour and violates how users and Shiny app authors expect Shiny to work.

Agree. I think this caveat is pretty well described in the draft.

The lack of a real way to store backend state means that storing refresh tokens is out. I'm pretty uncomfortable with the security implications of storing refresh tokens in cookies (as I think you've done here?)

I don't. The PR specifically highlights that refresh tokens would need to be figured out. You can opt-in to store the access token in an encrypted cookie by setting access_token_validity > 0.

it would seem to violate the OAuth standard's definition of a "confidential client".

Confidential clients are defined as Clients capable of maintaining the confidentiality of credentials (e.g., client implemented on a secure server with access to the client credentials), or capable of secure client authentication using other means. One could argue that storing refresh tokens as encrypted cookies maintains confidentiality of secrets, and others would argue that you need to maintain such secrets server-side, see e.g. stackoverflow discussions (1) and (2). If such an approach was chosen for this PR, it is still not clear that it violates the definition of an confidential client.

And without refresh tokens, the user experience is pretty poor, since you'd constantly have to log in again. That might be fine for providers like GitHub that give you an access token that lasts 8 hours, but it's extremely annoying -- from experience -- with a provider like Snowflake where tokens last only ten minutes.

I guess your mileage may vary depending on the provider. Lots of access tokens are valid for at least 60 minutes and could be useful without refresh tokens, even if suboptimal (e.g. Azure, Github, Google, Spotify).

The need to preregister redirect URLs with the provider in advance enforces a one-OAuth-client-per-Shiny-app model if Shiny is handling the redirects (not in every case, some providers allow multiple redirect URLs). This is fine if you have one or two apps that need OAuth. It's pretty uncomfortable if you deploy 10 dashboards that all need a different one of those Google JSON files.

My experience is actually the other way around. Most providers allow multiple redirects, while others requires a single redirect. E.g. Github only allows a single redirect, but Azure, Google and Spotify allows multiple. It can be a pain to manage, but it can also be relatively easily automated using IaC. This PR does not use json files, but environment variables (e.g. GOOGLE_CLIENT_ID) similar to the approach followed by authlib.

Shiny app authors rarely had the authority or ability to register an OAuth client themselves. This meant it could take weeks of back-and-forth with IT to get a client approved for a given redirect URL, if it was even possible at all. IT was often pretty uncomfortable with Shiny app authors managing sensitive OAuth client secrets themselves, too. Anecdotally, IT folks seem pretty happy that OAuth clients are managed by a Connect admin in our case, rather than by individual app authors.

I agree. And just to be clear, I'm not advocating this as an "enterprise-ready" PR. If anyone wants that, I would suggest they stick to Posit Connect or a certified OSS solution like mod_auth_openidc. This is a draft for suggesting and discussing a simple shiny OAuth solution which extends on the basis of previous work by @jcheng5. I had a need for it during a prototype, and thought it would benefit the community to share it when I saw #47.

My personal opinion is that Shiny is more useful with some form of OAuth support than without it. I'm more than happy to see it built in an entirely different way than this draft PR or dropped if that's what the maintainers prefer. I do feel it is one of the features that could help increase the adoption of shiny even further in the long term, though.

@hadley
Copy link
Member

hadley commented Oct 11, 2024

@thohan88 just a quick comment but I'm about to leave for vacation. I agree with your points that this doesn't need to be perfect, but it's useful to include something that's purely open source. I don't think we can do much better than this with our support on the deployment side, and it's fine for that better support to live in connect. I asked @atheriel to comment just so we can get a good sense of the pros and cons of both approaches.

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.

4 participants