--- title: "Advanced security configuration" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Advanced security configuration} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## 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. ```{r, eval = FALSE} 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. ```{r, eval = FALSE} 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. ```{r, eval = FALSE} 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. ```{r, eval = FALSE} 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=`. 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 ```{r, eval = FALSE} 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`. ```{r, eval = FALSE} 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) ``` ```{r, eval = FALSE} # 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. ```{r, eval = FALSE} 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: ```{r, eval = FALSE} 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. ```{r, eval = FALSE} 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: ```{r, eval = FALSE} resp <- perform_resource_req( auth$token, "https://api.example.com/me", # Lets shinyOAuth attach the DPoP proof and handle nonce challenges oauth_client = client ) ```