Skip to contents

Overview

Most apps can stay with the default flow in ‘shinyOAuth’. This vignette is for setups that need higher assurance. Some of these features are required by certain providers, while others are optional hardening measures. These include:

  • mTLS: makes the client prove itself with a certificate
  • JAR: signs the authorization request itself
  • PAR: pushes the authorization request over a back-channel first
  • form_post: sends the callback as an HTTP POST instead of query parameters
  • JARM: signs, and optionally encrypts, the authorization response
  • DPoP: binds tokens to a private key

For the normal package setup, see: vignette("usage", package = "shinyOAuth").

For a step-by-step explanation of where these features fit into the login flow, see: vignette("authentication-flow", package = "shinyOAuth").

Start with discovery when possible

If your provider supports OIDC discovery, start there. Discovery can fill in a lot of the provider metadata used by advanced security features, including PAR, JARM, DPoP algorithm hints, and mTLS endpoint aliases.

provider <- oauth_provider_oidc_discover(
  issuer = "https://id.example.com"
)

The sections below show the extra settings you usually add on top of your normal oauth_client() setup.

Mutual TLS (mTLS)

mTLS means your client presents a certificate during the TLS connection. That can do two useful things:

  • let the provider verify the client during the TLS handshake, so a client secret alone is not enough at the token endpoint
  • let the provider issue access tokens that are bound to that certificate, so a leaked token can be rejected without the matching client cert

Use it when your provider requires RFC 8705, or when you want certificate-bound access tokens.

provider <- oauth_provider(
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Use RFC 8705 client-certificate auth at the token endpoint
  token_auth_style = "tls_client_auth",
  # Use mTLS-specific endpoints when the provider publishes them
  mtls_endpoint_aliases = list(
    token_endpoint = "https://mtls.id.example.com/token",
    userinfo_endpoint = "https://mtls.id.example.com/userinfo"
  ),
  # Expect certificate-bound access tokens from the provider
  mtls_client_certificate_bound_access_tokens = TRUE
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Certificate and key sent on mTLS requests
  mtls_client_cert_file = "certs/client.pem",
  mtls_client_key_file = "certs/client-key.pem",
  mtls_client_ca_file = "certs/ca.pem",
  # Request certificate-bound tokens instead of plain Bearer tokens
  mtls_certificate_bound_access_tokens = TRUE
)

If your provider uses dynamic client registration, oauth_client_mtls_registration() can build the RFC 8705 registration metadata from the configured client.

JWT-secured authorization request (JAR)

JAR wraps the authorization request in a signed JWT called a Request Object. That lets the provider verify exactly which request parameters the client meant to send, instead of trusting a browser URL that could be changed on the way.

This is useful when you want stronger protection against request tampering, or when the provider requires signed Request Objects. If you also configure Request Object encryption, the request gains confidentiality as well.

provider <- oauth_provider(
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Provider expects signed Request Objects
  signed_request_object_required = TRUE,
  request_parameter_supported = TRUE,
  request_object_signing_alg_values_supported = c("RS256"),
  request_object_encryption_alg_values_supported = c("RSA-OAEP"),
  request_object_encryption_enc_values_supported = c("A128CBC-HS256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Signing key for the Request Object
  client_assertion_private_key = openssl::read_key("keys/client-key.pem"),
  # Send the authorization request as a JWT in the request parameter
  request_object_mode = "request",
  request_object_signing_alg = "RS256",
  # Optional: encrypt the Request Object as well
  request_object_encryption_alg = "RSA-OAEP",
  request_object_encryption_enc = "A128CBC-HS256"
)

If your main goal is to keep request details out of the browser URL, PAR is usually the simpler choice. JAR and PAR also work well together.

Pushed authorization requests (PAR)

PAR sends the authorization request from your server to the provider first. The browser then gets redirected with a short request_uri handle instead of the full request details.

This is useful when the provider requires PAR, when the request is large, or when you want the provider to receive and validate the request before the browser redirect happens. It also keeps most request details out of browser history and front-channel logs.

provider <- oauth_provider(
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Enable pushed authorization requests
  par_url = "https://id.example.com/par",
  par_required = TRUE,
  # Keep the browser redirect down to client_id + PAR request_uri
  authorization_request_front_channel_mode = "minimal"
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile")
)

If you set request_object_mode = "request_uri", shinyOAuth still builds a signed Request Object, but instead of putting that JWT directly on the browser redirect as request=..., it publishes the Request Object at a URL and sends the provider request_uri=<that URL>. The provider then fetches that published Request Object itself.

With oauth_module_server(), that published URL is served by the Shiny app itself. In other words, shinyOAuth creates a short-lived Request Object endpoint under the app, and the provider makes an outbound HTTP request back to that app URL to read the signed Request Object.

That means two extra things matter:

  • the published URL must be reachable from the provider, not just from the user’s browser
  • if the provider requires pre-registered request_uri values, the public URL or wildcard prefix must already be registered there
client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  client_assertion_private_key = openssl::read_key("keys/client-key.pem"),
  # Publish the Request Object by reference instead of sending it inline
  request_object_mode = "request_uri",
  request_object_signing_alg = "RS256"
)

# Inside server()
auth <- oauth_module_server(
  "auth",
  client,
  auto_redirect = TRUE,
  # Public HTTPS base URL of this Shiny app as seen by the provider
  request_uri_base_url = "https://shiny.yourdomain.com/myapp"
)

Form Post response mode

response_mode = "form_post" tells the provider to send the authorization response back as an HTTP POST body instead of query parameters on the URL. The body still contains normal OAuth fields such as code, state, error, and iss.

This is useful when the provider requires form_post, or when you want those callback values out of the browser URL, history, and front-channel logs. It changes the callback transport, but it does not sign or encrypt the response by itself.

Two common setups are shown below. The first uses the normal app URL as the redirect_uri. The second uses a dedicated callback path such as /callback.

base_ui <- fluidPage(
  uiOutput("login")
)

# Example 1: redirect_uri is the normal app URL
client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com",
  scopes = c("openid", "profile"),
  # Ask the provider to POST the callback to redirect_uri
  response_mode = "form_post"
)

# Wrap the UI so shinyOAuth can accept the POST before a Shiny session exists
ui <- oauth_form_post_ui(base_ui, id = "auth", client = client)

app <- shinyApp(ui, server)
# Example 2: redirect_uri uses a separate callback path
client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/callback",
  scopes = c("openid", "profile"),
  response_mode = "form_post"
)

base_ui <- fluidPage(
  uiOutput("login")
)

ui <- oauth_form_post_ui(base_ui, id = "auth", client = client)

# If the callback path is not the app root, route all paths through this UI
app <- shinyApp(ui, server, uiPattern = ".*")

oauth_form_post_ui() uses the path from redirect_uri by default. Set its callback_path argument only when the POST callback needs to land on a different public path than the one derived from redirect_uri.

JWT-secured authorization response mode (JARM)

JARM makes the authorization response itself a JWT. Instead of trusting plain callback parameters immediately, the app validates a signed JWT first, and can also decrypt it when the provider uses encrypted JARM.

This is useful when you want the app to verify that the callback really came from the provider and was not changed in the front channel. With encrypted JARM, the callback contents are also hidden from browser-visible layers.

provider <- oauth_provider(
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Advertise the JARM response modes and algorithms this provider supports
  response_modes_supported = c("query", "query.jwt", "form_post.jwt"),
  jarm_signing_alg_values_supported = c("RS256"),
  jarm_encryption_alg_values_supported = c("RSA-OAEP"),
  jarm_encryption_enc_values_supported = c("A128CBC-HS256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Ask for a JWT-wrapped authorization response
  response_mode = "query.jwt",
  jarm_signed_response_alg = "RS256"
)

For encrypted JARM, add the decryption settings:

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  response_mode = "query.jwt",
  jarm_signed_response_alg = "RS256",
  # Optional: decrypt JARM before validating the signed payload
  jarm_encrypted_response_alg = "RSA-OAEP",
  jarm_encrypted_response_enc = "A128CBC-HS256",
  jarm_decryption_private_key = openssl::read_key("keys/jarm-decrypt.pem")
)

JARM is currently intended for oauth_module_server(). If you use response_mode = "form_post.jwt", wrap your UI with oauth_form_post_ui().

Demonstrating proof-of-possession (DPoP)

DPoP adds a proof JWT to token and API requests and lets the server bind the access token to your key. That means a copied token is less useful, because the attacker would also need the private key that creates the proofs.

Use it when your authorization server or resource server supports DPoP and you want sender-constrained tokens without managing client certificates.

provider <- oauth_provider(
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Optional metadata check for acceptable DPoP signing algorithms
  dpop_signing_alg_values_supported = c("ES256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile", "api.read"),
  # Private key used to sign DPoP proofs
  dpop_private_key = openssl::read_key("keys/dpop-key.pem"),
  dpop_signing_alg = "ES256"
)

After login, keep using the request helpers instead of adding Authorization or DPoP headers manually:

resp <- perform_resource_req(
  auth$token,
  "https://api.example.com/me",
  # Lets shinyOAuth attach the DPoP proof and handle nonce challenges
  oauth_client = client
)