--- title: "Advanced security configuration" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Advanced security configuration} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Overview Some login providers require extra proof that a request comes from your app, or extra protection for the tokens they issue. Start with the working app in [Usage](usage.html), then configure the features your provider requires. A signature lets the receiver check who sent a message and whether it changed. Encryption hides its contents. A client certificate or signing key can also tie an access token to your app, so the token alone is not enough to use it. The features below depend on support from the provider and, for token binding, the API. | Need | Feature | | --- | --- | | Prove the app's identity with a signing key | [JWT client authentication](#jwt-client-authentication) | | Present a registered certificate | [Mutual TLS](#mutual-tls-mtls) | | Send request details directly to the provider | [PAR](#pushed-authorization-requests-par) | | Sign the login request | [JAR](#jwt-secured-authorization-request-jar) | | Return login results outside the URL or with a signature | [Form Post](#form-post-response-mode) or [JARM](#jwt-secured-authorization-response-mode-jarm) | | Require a matching key when using a token | [DPoP](#demonstrating-proof-of-possession-dpop) | The examples use placeholder addresses and key files for the app registration. ## Provider metadata and OIDC discovery Discovery reads the provider's published settings so you do not have to copy each endpoint and supported feature manually: ```{r, eval = FALSE} provider <- oauth_provider_oidc_discover( issuer = "https://id.example.com" ) ``` ## JWT client authentication With `private_key_jwt`, your app signs a short-lived JSON Web Token (JWT) when requesting tokens instead of sending a shared secret. Register the public key with the provider, then configure the matching private key: ```{r, eval = FALSE} provider <- oauth_provider_oidc_discover( "https://id.example.com", token_auth_style = "private_key_jwt" ) client <- oauth_client( provider = provider, client_id = "client-id", redirect_uri = "https://app.example.com", scopes = c("openid", "profile"), client_assertion_private_key = openssl::read_key("keys/client-key.pem"), client_assertion_private_key_kid = "registered-key-id" ) ``` The provider uses the public key to verify the app's message. For secret-based assertions, see [oauth_client()](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html). See [endpoint-specific authentication](#endpoint-specific-authentication) for credentials and audience settings. ## Mutual TLS (mTLS) mTLS lets your app present a certificate during the secure network connection. It can identify the app to the provider. Separately, a provider can issue certificate-bound tokens that an API accepts only with the matching certificate. Configure the use agreed with your provider: ```{r mtls-provider, eval = FALSE} provider <- oauth_provider( name = "example-mtls", # Exact OIDC issuer; enables nonce and ID-token validation issuer = "https://id.example.com", auth_url = "https://id.example.com/authorize", token_url = "https://id.example.com/token", jwks_uri = "https://id.example.com/jwks", userinfo_url = "https://id.example.com/userinfo", # 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 ) ``` ```{r, eval = FALSE} 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", # Require the matching certificate when access tokens are used mtls_certificate_bound_access_tokens = TRUE ) ``` By default, shinyOAuth requires visible confirmation of the token's certificate binding and verifies it. If your provider uses opaque tokens, see the [certificate-binding details below](#certificate-binding-and-platform-requirements). On Windows, separate PEM certificate and key files require curl's OpenSSL backend. Follow the platform setup in that section before testing mTLS. ## Pushed authorization requests (PAR) PAR sends login-request details directly from your server to the provider. The browser carries a short reference instead of the full request, which keeps most details out of browser history and avoids very long URLs. ```{r, eval = FALSE} provider <- oauth_provider( name = "example-par", 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") ) ``` ## JWT-secured authorization request (JAR) JAR signs the login request so the provider can detect changes to its parameters. It can be combined with PAR. The app's provider registration holds the public signing key and the requirement for signed requests; shinyOAuth's local setting controls request construction. ```{r, eval = FALSE} provider <- oauth_provider( name = "example-jar", issuer = "https://id.example.com", auth_url = "https://id.example.com/authorize", token_url = "https://id.example.com/token", # The server registration must already require signed Request Objects signed_request_object_required = TRUE, request_parameter_supported = TRUE, request_object_signing_alg_values_supported = c("RS256") ) 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"), # 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" ) ``` The [unsigned-request example below](#testing-signed-request-enforcement) illustrates the provider-side enforcement check. If your provider fetches signed requests from the app, see [Request Objects published by the Shiny app](#request-objects-published-by-the-shiny-app). ## Form Post response mode Form Post returns the login result in an HTTP request body instead of the browser URL, keeping callback values out of browser history. Signing and encryption are separate features. Set `response_mode = "form_post"` on the client, then use the matching wrapper: ```{r, eval = FALSE} base_ui <- shiny::fluidPage(shiny::textOutput("status")) ui <- oauth_form_post_ui(base_ui, id = "auth", client = client) server <- function(input, output, session) { auth <- oauth_module_server("auth", client) output[["status"]] <- shiny::renderText({ if (isTRUE(auth[["authenticated"]])) "Signed in" else "Waiting for login" }) } app <- shiny::shinyApp(ui, server, uiPattern = ".*") ``` `uiPattern = ".*"` allows Shiny to serve the dedicated callback path. If your proxy accepts HTTPS but forwards HTTP to Shiny, the wrapper needs a trusted way to recover the public callback address. See the [HTTPS proxy example below](#deployment-behind-an-https-proxy). ## JWT-secured authorization response mode (JARM) JARM signs the returning login result. shinyOAuth verifies it before accepting the callback fields, giving the app a way to check the response's sender and integrity. Select the mode and signing algorithm supported by your registration: ```{r, eval = FALSE} provider <- oauth_provider( name = "example-jarm", 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", client_secret = "client-secret", 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" ) ``` JARM uses `oauth_module_server()`. With `form_post.jwt`, also use `oauth_form_post_ui()`. Optional response encryption requires additional client settings and support for local R subprocesses; see the [encrypted response example below](#encrypted-authorization-responses). ## Demonstrating proof-of-possession (DPoP) DPoP ties an access token to a signing key. Each request includes fresh proof that the app holds that key, so an API enforcing DPoP cannot accept a stolen token on its own. The provider and the API must both support it. ```{r, eval = FALSE} provider <- oauth_provider( name = "example-dpop", 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", client_secret = "client-secret", 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, let the request helper attach the proof and handle provider nonce challenges: ```{r, eval = FALSE} resp <- perform_resource_req( auth[["token"]], "https://api.example.com/me", # Lets shinyOAuth attach the DPoP proof and handle nonce challenges client = client ) ``` The helper uses `client = client` to supply the matching key or certificate. For DPoP tokens it rejects automatic redirects, since the proof is tied to the request's method and address. ## Browser binding and deployment boundary The module links a pending login to browser session storage and a temporary cookie. Callback validation checks both against the initiating transaction; browser setup and binding rotation happen automatically through the UI wrapper. Session storage is scoped to the app's origin and tab. Cookies are shared across ports on a hostname, and scripts on the app's origin can access its storage. With `oauth_ui()` or `oauth_form_post_ui()`, callbacks are processed before application UI loads. Low-level integrations built directly on `prepare_call()` and `handle_callback()` supply their own browser-binding and HTTP handling. ## Endpoint-specific authentication For the client assertion profile referenced by OAuth 2.1 draft 16, use the trusted issuer as the audience and optionally select the recommended type: ```{r, eval = FALSE} client@client_assertion_audience <- provider@issuer client@client_assertion_typ <- "client-authentication+jwt" ``` [RFC7523bis draft 11 section 4](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-11#section-4) describes these settings. They apply to client authentication assertions, not JAR, JARM or ID token headers. Match them to your provider's registration. PAR, introspection and revocation can each require different authentication: ```{r, eval = FALSE} client@endpoint_auth <- list( introspection = list( token_auth_style = "header", client_id = "registered-inspector", client_secret = Sys.getenv("INTROSPECTION_SECRET"), extra_headers = c("X-App" = "registered-app") ), revocation = list( token_auth_style = "private_key_jwt", client_assertion_private_key = openssl::read_key("keys/revocation-key.pem"), client_assertion_alg = "RS256", client_assertion_audience = "https://id.example.com/revocation" ) ) ``` Unspecified credentials inherit the client settings. Each endpoint has its own authentication method and audience. `extra_token_headers` applies only to exchange and refresh; `extra_headers` covers other endpoints. ## Certificate binding and platform requirements `mtls_certificate_bound_access_tokens = TRUE` enables certificate presentation and mTLS endpoint selection, independently of the client authentication method. By default, `mtls_require_observed_cnf = TRUE` requires visible binding data that matches the certificate. Some providers use opaque tokens whose binding only their servers can check. `mtls_require_observed_cnf = FALSE` permits missing local confirmation for opaque tokens: ```{r, eval = FALSE} client <- oauth_client( provider = provider, client_id = "client-id", redirect_uri = "https://app.example.com/auth/callback", mtls_client_cert_file = "certs/client.pem", mtls_client_key_file = "certs/client-key.pem", mtls_certificate_bound_access_tokens = TRUE, mtls_require_observed_cnf = FALSE ) ``` Any observed certificate mismatch is rejected. The provider and API enforce binding; see [RFC 8705 section 3](https://www.rfc-editor.org/rfc/rfc8705.html#section-3). On Windows, separate PEM certificate/key files require curl's OpenSSL backend. Set `CURL_SSL_BACKEND=openssl` in `.Renviron` and restart R before loading curl, httr2 or shinyOAuth. Check the active backend with `curl::curl_version()[["ssl_version"]]`; shinyOAuth rejects Schannel for this configuration. See the [curl certificate guide](https://curl.se/libcurl/c/CURLOPT_SSLCERT.html). For dynamic registration, `oauth_client_mtls_registration()` builds the matching certificate registration metadata. ## Testing signed-request enforcement This request uses the same client registration but omits the signed Request Object: ```{r, eval = FALSE} unsigned <- httr2::request(provider@auth_url) |> httr2::req_url_query( client_id = client@client_id, redirect_uri = client@redirect_uri, response_type = "code", scope = "openid profile", state = "unsigned-policy-probe", code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method = "S256" ) |> httr2::req_options(followlocation = FALSE) |> httr2::req_error(is_error = function(resp) FALSE) |> httr2::req_perform() httr2::resp_status(unsigned) httr2::resp_headers(unsigned) httr2::resp_body_string(unsigned) ``` Provider enforcement means this otherwise valid request is rejected for the missing signature while a signed request succeeds. An HTTP error, login page or `login_required` response alone leaves that result unresolved. To encrypt the signed request, select supported `request_object_encryption_alg` and `request_object_encryption_enc` values, and configure a provider encryption key. See [oauth_client()](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html) and [oauth_provider()](https://lukakoning.github.io/shinyOAuth/reference/oauth_provider.html). PAR can also carry the signed object while keeping most details out of the URL. ## Request Objects published by the Shiny app With `request_object_mode = "request_uri"`, shinyOAuth publishes the signed request at a short-lived URL for the provider to fetch. This differs from PAR, where the provider issues the reference. Providers requiring PAR cannot use app-hosted Request Objects in its place. The provider must be able to reach the public HTTPS URL. Register it or its prefix if your provider requires this: ```{r, eval = FALSE} request_uri_provider <- oauth_provider( name = "example-request-uri", issuer = "https://id.example.com", auth_url = "https://id.example.com/authorize", token_url = "https://id.example.com/token", request_uri_parameter_supported = TRUE, request_object_signing_alg_values_supported = "RS256" ) client <- oauth_client( provider = request_uri_provider, client_id = "client-id", client_secret = "client-secret", 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" ) ``` Use `oauth_ui()` or `oauth_form_post_ui()` with the same client and state store as the server. Multiple R processes need a shared store with atomic `take()`; see [custom_cache()](https://lukakoning.github.io/shinyOAuth/reference/custom_cache.html). A live retrieval handle permits one-time consumption of the object. Signing leaves its claims readable; Request Object encryption supplies confidentiality. The `"minimal"` front-channel mode is for compatible PAR providers; it is not available for OIDC inline or app-hosted signed requests. ## Deployment behind an HTTPS proxy If your web server accepts HTTPS but forwards HTTP to Shiny, the wrapper needs a trusted way to recover the public request address. Configure `request_uri_resolver` for your own proxy. This example accepts one proxy IP and a fixed public origin, including a mounted app path: ```{r, eval = FALSE} trusted_proxy_uri <- function(req) { if (!identical(req[["REMOTE_ADDR"]], "10.0.0.10") || !identical(req[["HTTP_X_FORWARDED_PROTO"]], "https")) { return(NULL) } paste0("https://app.example.com", req[["SCRIPT_NAME"]], req[["PATH_INFO"]]) } ui <- oauth_form_post_ui( base_ui, id = "auth", client = client, request_uri_resolver = trusted_proxy_uri ) ``` This resolver accepts forwarded metadata from the configured proxy address. The wrapper checks the result against the registered redirect origin and callback path. ## Encrypted authorization responses For encrypted JARM, add the decryption settings: ```{r, eval = FALSE} 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"), 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") ) ``` Use `oauth_module_server()` and, for `form_post.jwt`, `oauth_form_post_ui()`. Encrypted callbacks use a temporary local R process, so hosting must permit subprocesses and allow for startup time. ## Token binding requirements and validation With a DPoP key configured, `dpop_require_access_token` defaults to `TRUE`. The provider must return a DPoP token, and any visible key binding must match. `dpop_require_observed_cnf = TRUE` also requires local confirmation; opaque tokens may need introspection to provide it. Certificate-bound tokens similarly need matching certificate data. Refresh must provide fresh binding data; shinyOAuth does not reuse the old certificate thumbprint when the new response omits it. Malformed binding data is rejected. Reading binding data from a JWT access token does not verify that token's signature. Introspection can confirm it with the provider. The API must also enforce binding for a stolen token to be unusable without its key or certificate. ### DPoP in API requests `perform_resource_req()` and `get_userinfo()` handle a nonce challenge with one fresh-proof retry. Proofs bind the request's method and address, and the helpers reject automatic redirects. The `query` argument preserves the URL form used when signing. `perform_resource_req()` also applies decoded response-size limits. Direct `httr2::req_perform()` uses httr2's transport instead. If your provider issues unusually large nonces, see `shinyOAuth.dpop_nonce_max_bytes` in [package options](package-options.html). ## Policy identity across callbacks and retained credentials Pending logins and saved connections remember the client and provider policies that created them. Policy changes invalidate those records. After upgrades that change the policy encoding, users must sign in again; update workers that share keys and storage together. If a custom `userinfo_id_selector` depends on external settings, assign a `userinfo_id_selector_policy_id` and change it when those settings or helper behavior change. This identifies policy inputs outside the function body. The Spotify helper tracks its `allow_legacy_id` setting automatically. ## Issuer comparison and transport policy `compare_callback_issuer = TRUE` compares `iss` exactly with the trusted `provider@issuer`; `enforce_callback_issuer` also requires it to be present. Providers advertising RFC 9207 support require the identifier, while validated JARM supplies its own issuer. Apps using several providers still need issuer identification or distinct callback routes. For legacy providers that omit `iss`, comparison can remain enabled while presence is optional. The flags control these checks separately; see [oauth_client()](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html). Package HTTPS requests require TLS 1.2 or later by default. `options(shinyOAuth.tls_min_version = "1.3")` selects a higher minimum; set it before discovery or login. Browser and proxy TLS need separate configuration. See [package options](package-options.html#http-settings-timeout-retries-user-agent). ## Callback pages and application scripts For query and query-JARM callbacks, `oauth_ui(ui, id = "auth", client = client)` processes callbacks before loading application UI and sets privacy headers. Its ID and client identify the corresponding `oauth_module_server()` instance. Response headers control browser caching and referrers, independently of upstream hosting and proxy logs. The standard UI wrappers isolate raw callbacks from application scripts; custom `use_shinyOAuth()` integrations provide their own HTTP callback handling. The registered redirect URI declares fixed routing query parameters; continuation discards unregistered inbound parameters. Behind an HTTPS proxy, `request_uri_resolver` supplies the public address under the trust policy described for `oauth_form_post_ui()`. ## Provider key-set validation shinyOAuth checks the entire provider key set before selecting a signing key. A malformed entry can therefore interrupt login or signed responses even if another key would work. Providers must publish valid entries throughout key rotation; see [RFC 7517 section 5](https://www.rfc-editor.org/rfc/rfc7517.html#section-5) for the standard's less restrictive recommendation. shinyOAuth rejects conflicting signing algorithms for one RSA key, including when reused under different key IDs. Key rotation changes the key and can require corresponding pin, policy and cache updates; changing only `kid` does not change the key. See [oauth_provider()](https://lukakoning.github.io/shinyOAuth/reference/oauth_provider.html) for pinning and key-selection settings. ## Trusted ID-token audiences ID tokens with multiple audiences remain rejected by default. If an issuer legitimately includes another trusted audience, configure `trusted_id_token_audiences = c("trusted-service")` on `oauth_client()`. shinyOAuth still requires this client's ID in `aud` and a matching `azp` when present. Trusted multiple audiences do not require an `azp` claim under [OIDC Core section 3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). ## Signature and encryption support The signing algorithm must be supported by both the provider and shinyOAuth. Outgoing defaults depend on the key: RSA defaults to `RS256`, with `client_assertion_alg = "RS384"` available for registrations requiring RS384. | Use | Supported algorithms | | --- | --- | | Private-key assertions, JAR and DPoP | `RS256`, `RS384`, `ES256`, `ES384`, `ES512`, and `EdDSA` or `Ed25519` with Ed25519 keys. | | Secret-based assertions and JAR | `HS256`, `HS384`, `HS512`. | | Request Object encryption and JARM decryption | `RSA-OAEP` with `A128CBC-HS256`, `A192CBC-HS384` or `A256CBC-HS512`. | Outgoing RSA-PSS and Ed448 signatures are unsupported. For encryption, `RSA-OAEP-256` and GCM JWE modes are unsupported, as are encrypted ID tokens and encrypted UserInfo. Incoming signature policies are separate. See [oauth_provider()](https://lukakoning.github.io/shinyOAuth/reference/oauth_provider.html) and the `jarm_*` settings in [oauth_client()](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html).