---
title: "Advanced security configuration"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Advanced security configuration}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

## Overview

This vignette documents the configuration of OAuth 2.0 and OpenID Connect
extensions supported by shinyOAuth: JWT client authentication, mutual TLS
(mTLS), signed authorization requests (JAR), pushed authorization requests
(PAR), Form Post responses, signed authorization responses (JARM), and tokens
bound to a private key (DPoP).

These features require corresponding support and configuration at the provider.
The examples extend the provider and client setup in the [usage vignette](usage.html).
Replace placeholder domains, credentials, and key paths with your registered
values. Each example shows the settings for the feature being discussed.

## Browser binding and deployment boundary

Use HTTPS and treat every service on the same hostname as trusted, including
services on other ports. Cookie scope does not include the port, as specified
in [RFC 6265 section 8.5](https://www.rfc-editor.org/rfc/rfc6265.html#section-8.5).
The `__Host-` prefix prevents sibling-domain cookie injection; it does not
isolate ports. Signed or `HttpOnly` cookies still reach same-host services in
HTTP requests. Use a dedicated hostname when those services are untrusted.

shinyOAuth keeps the actual browser-binding token in origin- and tab-scoped session
storage, accompanied by an independent random cookie marker. It restores the
binding only when the cookie matches an unexpired local record. Reading or
planting a cookie on another port cannot establish that record. Cookie
disruption can still abort login; pages and scripts on the same origin can
access session storage, so XSS prevention remains necessary. Cookies, session
storage, and Web Crypto must be available, and pending logins from older
cookie-only or local-storage versions must be restarted after upgrading.

Each new authorization request gets a fresh server-selected binding and a distinct
marker cookie. Idle predecessor markers are removed when the new binding is
ready. Predecessors associated with pending transactions (or older records whose
transaction status is unknown) remain until their original TTL expires, so a
cloned tab cannot invalidate the original tab's pending login. Application
callback routes and tabs have independent records; complete login in the tab
that started it. Private
module inputs are excluded from both URL and disk bookmarks. Avoid copying
browser tokens into custom bookmark values or logs. The acknowledgment is a
delivery check, not independent server verification of HTTP cookie possession.
The standalone `prepare_call()` and `handle_callback()` APIs still require
the caller to establish and protect their own browser binding.

## Provider metadata and OIDC discovery

`oauth_provider_oidc_discover()` reads provider metadata used by these
features, including PAR support, JARM and DPoP algorithms, 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.

## JWT client authentication

Some providers require your app to sign a short statement proving its
identity when requesting tokens. This **client assertion** is a JWT (JSON
Web Token). It identifies the app, rather than the user signing in.

For a registered private key, select the method during provider setup and
supply the key when creating your client:

```{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"
)
```

Register the corresponding public key with your provider. For
`client_secret_jwt`, select that method and supply a sufficiently strong
`client_secret` instead. `client_assertion_alg` has a key-compatible default;
`client_assertion_audience` overrides the expected recipient if your provider
requires a value other than the token request URL.

For the client assertion profile referenced by OAuth 2.1 draft 16, select the
trusted authorization-server issuer as the sole audience and optionally use
the recommended explicit 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)
requires the issuer audience and recommends this type. The package preserves
the existing `JWT` type and request-endpoint audience defaults, including PAR's
existing issuer handling. The checker treats audience mismatches as mandatory
findings and legacy typing as an advisory. These settings affect client
authentication assertions, including retries, and do not change JAR, JARM or ID
token headers. The same settings can be selected in `endpoint_auth` overrides.

Authentication can differ at PAR, introspection, and revocation endpoints.
Discovery preserves their independent method and algorithm metadata; configure
credentials and audiences to match each endpoint's registration agreement:

```{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's settings. Advertised methods and
JWT algorithms are checked per endpoint. Discovery defaults omitted revocation
methods to Basic authentication; omitted introspection methods have no standard
default, so confirm the configured method with your provider. PAR inherits the
token authentication settings unless explicitly overridden. `extra_token_headers`
now applies only to exchange and refresh: opt in through `extra_headers` at
each other endpoint that should receive those headers, even on the same origin.

## Issuer comparison and transport policy

`compare_callback_issuer = TRUE` compares a supplied callback `iss` exactly
against `provider@issuer`. Pair it with `enforce_callback_issuer = FALSE` when
absence is permitted for your older provider. Required presence always implies
comparison. An explicitly supplied legacy `enforce_callback_issuer = FALSE`
retains complete opt-out unless comparison is explicitly enabled. Otherwise,
issuer-configured clients enable comparison automatically.

Participating clients require `iss` when the provider advertises RFC 9207
support. Validated JARM supplies its own issuer and does not require a redundant
outer value. Multi-server applications must retain their selected issuer or
distinct-route defense. The expected issuer comes from trusted configuration;
it is not inferred from a token URL or normalized before comparison.
See [RFC 9207 section 2.4](https://www.rfc-editor.org/rfc/rfc9207.html#section-2.4).

`options(shinyOAuth.tls_min_version = "1.2")` requires TLS 1.2 or later on
package HTTPS requests; `"1.3"` selects a higher minimum and `NULL` preserves
runtime defaults. Configure it before discovery or login. The policy preserves
stronger supplied minima, compatible maxima and custom CA roots. It also
applies in async workers and participates in pending-login policy checks.
An absent option is insufficient evidence about an actual handshake; the
checker reports older unresolved backend defaults as unknown. Browser/proxy
hops and future resource requests need separate validation. Details and
constraints are in [package options](package-options.html#http-settings-timeout-retries-user-agent).

## Callback pages and application scripts

Use `oauth_ui(ui, id = "auth", client = client)` for query and query-JARM
responses. The ID and client must match `oauth_module_server()`. The wrapper
validates callbacks and stores sealed, short-lived responses, then redirects
to a one-time bridge URL before invoking the application UI. State remains
single-use and browser-bound. Missing bridge configuration rejects raw
callbacks; existing `oauth_ui(ui)` setups must add `id` and `client`.

Callback and HTML responses send `Cache-Control: no-store`, `Pragma: no-cache`,
and `Referrer-Policy: no-referrer`. These do not remove upstream access logs:
configure proxies and hosting logs to omit callback queries. Keep third-party
scripts off any unsanitized callback page, including when using
`use_shinyOAuth()` with a custom HTTP integration. Register fixed tenant or
routing query parameters in the redirect URI; unregistered inbound parameters
are discarded on continuation. For trusted HTTPS-terminating proxies, use
`request_uri_resolver` with the same trust checks as `oauth_form_post_ui()`.

## Provider key-set validation

shinyOAuth validates the whole fetched JWKS before selecting a key by `kid`,
usage, operations, or algorithm. A malformed RSA, EC, or OKP public entry can
therefore reject the set even when another entry would validate the token.
This is a deliberately stricter availability policy than the recommendation
to ignore individual unusable keys in [RFC 7517 section 5](https://www.rfc-editor.org/rfc/rfc7517.html#section-5).
Providers must publish structurally valid public entries throughout key rotation;
an unrelated broken entry can interrupt login, signed UserInfo, JARM, or Request
Object encryption until the provider repairs its JWKS.

Unknown key types are ignored for selection after common structural checks.
Malformed set structure, duplicate JSON members, and secret key material are
rejected. With pinning mode `"any"`, at least one supported public key must match
a configured thumbprint, and selected keys must be pinned. Mode `"all"` requires
every RSA, EC, and OKP entry in the set to have a computable, configured thumbprint,
including entries that would later be filtered out. Neither mode skips the
whole-set structural checks or the selected key's strength checks.

## 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()`.
The token must still include this client's ID in `aud`, and `azp` must equal
this client's ID 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).
Other audiences, incorrect authorized parties, and invalid signatures are rejected.

## Mutual TLS (mTLS)

With mutual TLS (mTLS), the client presents a certificate during the TLS
connection. OAuth 2.0 uses this for certificate-based client authentication
and for certificate-bound access tokens (RFC 8705). With certificate-bound
tokens, the API requires the matching certificate when accepting a token.
The provider must support the selected use of mTLS.

```{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
)
```

`mtls_certificate_bound_access_tokens = TRUE` enables certificate presentation
and mTLS endpoint-alias selection independently of the OAuth client
authentication method. Configuring certificate files alone does not enable
certificate-bound token requests.

The separate `mtls_require_observed_cnf` policy defaults to `TRUE`, preserving
strict local assurance: shinyOAuth must observe `cnf[["x5t#S256"]]` in the token
response, a JWT access token, or introspection and match it to the configured
certificate. An opaque bound token without observable binding fails this policy.

RFC 8705 also permits opaque tokens whose binding is known only to the
authorization and resource servers. For that deployment, configure the
certificate/key and provider capability as above, then use both settings:

```{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
)
```

This mode still presents the certificate on token, refresh, UserInfo, and
protected-resource requests, including with body authentication, public
clients, or `private_key_jwt`. It prefers configured mTLS aliases and falls
back to the configured endpoint URL when no alias is available. The servers
must enforce binding; shinyOAuth allows missing confirmation but still
rejects any observed mismatch or conflicting confirmation claims. 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, or run
`Sys.setenv(CURL_SSL_BACKEND = "openssl")` before loading curl, httr2, or shinyOAuth
in a fresh session. Check `curl::curl_version()[["ssl_version"]]`: parenthesized
backends are inactive alternatives. If OpenSSL is unavailable, install a curl
build that provides it. shinyOAuth rejects an active Schannel backend for this
PEM configuration before sending the request. See the
[libcurl certificate documentation](https://curl.se/libcurl/c/CURLOPT_SSLCERT.html).

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 protects authorization request parameters with a signature. The client
sends them in a JWT called a Request Object, which the provider verifies
before processing the request. Optional Request Object encryption also protects
the request contents. Both settings must match the provider registration.

For enforced request integrity, configure the authorization server to require
signed Request Objects for this client, for example with the RFC 9101 client
registration field `require_signed_request_object = true`, and register its
signing key and allowed algorithm. Confirm the server actually enforces this
policy. Setting `signed_request_object_required = TRUE` below describes that
server policy and enforces local construction; it does not register or change
the client at the server. If unsigned requests remain accepted, signing is
optional and cannot prevent a downgrade to unsigned authorization requests
([RFC 9101 section 10.5](https://www.rfc-editor.org/rfc/rfc9101.html#section-10.5)).

```{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"
)
```

Test the server policy before deployment: send an otherwise valid authorization
request for this same client without `request` or `request_uri`. For example:

```{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)
```

The negative test passes only when the server explicitly rejects the request
because the required signed Request Object is missing. Check its documented
error response or server audit event for that reason; a generic HTTP error is
insufficient. A login/consent page, authorization code, or `login_required`
response does not establish enforcement. Repeat this probe in deployment tests
alongside a successful signed request.

Register the signing key with your provider. To encrypt the signed request
too, configure `request_object_encryption_alg = "RSA-OAEP"` and
`request_object_encryption_enc` to a supported AES-CBC-HMAC value such as
`"A128CBC-HS256"`. The provider must publish a suitable encryption key or
you must supply `request_object_encryption_jwk` to [`oauth_provider()`](https://lukakoning.github.io/shinyOAuth/reference/oauth_provider.html).
See [`oauth_client()`](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html)
for supported algorithms and the provider reference for encryption key selection.

PAR, described below, keeps most request details out of the browser URL.
It can also carry a signed Request Object, combining PAR with JAR.

### Request Objects published by the Shiny app

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.

`oauth_module_server()` serves the Request Object at a short-lived URL
under the Shiny app. The provider must be able to request that URL directly.

This mode is separate from PAR and cannot be used when the provider requires
PAR. With PAR, the provider issues the reference; with this mode, the provider
fetches a URL published by your app.

Deployment requirements:

- the published URL must use HTTPS and 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}
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"
)
```

Wrap the app UI in `oauth_ui(ui, id = "auth", client = client)` (or
`oauth_form_post_ui()` for POST callbacks). The app root serves Request Objects
using a random, single-purpose `shinyOAuth_request_object` query handle. Only
its digest is stored alongside the object in `client@state_store`. GET consumes
it atomically; HEAD does not consume it. Retrieval expires at the earlier of the
object expiry and 120 seconds after publication. Cache eviction can shorten
availability; the store's retention policy controls physical cleanup.
If the provider requires `request_uri` registration, update its permitted URLs
to cover this app-root endpoint when migrating from the old `/session/` paths.
For multiple workers, use a shared state store with atomic `take()`; a memory
store works only when publication and retrieval reach the same R process.

The hosted JWT and its expiry occupy a separate store record, outside the
pending-login record's `state_key` sealing. Signing leaves JWT claims readable;
enable Request Object JWE encryption for claim confidentiality. See the
[`custom_cache()` shared-store contract](https://lukakoning.github.io/shinyOAuth/reference/custom_cache.html)
for record protection and expiry requirements.

Earlier versions used Shiny's raw live session token in `registerDataObj()`
URLs. Those URLs could disclose a capability also used by other session
resources through browser, provider, or proxy logs. The new handles contain no
session token. Still redact request URLs in logs and limit log retention:
disclosing a live handle allows its holder to consume the pending object.
Prefer PAR when supported to use a provider-issued reference and avoid a
public app retrieval endpoint. OIDC signed requests retain outer `client_id`,
`response_type`, and `scope` parameters. `authorization_request_front_channel_mode =
"minimal"` is available for compatible PAR providers, but is rejected for
OIDC inline or client-published signed requests.

## 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.

PAR allows the provider to validate the request before the browser redirect
and avoids placing large requests in a URL. It also keeps most request details
out of browser history and logs of browser requests. Set `par_required = TRUE`
when the provider requires PAR.

```{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")
)
```

## 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`.

Use `form_post` when required by the provider or to keep callback values out
of the browser URL, history, and logs of browser requests. It changes the
callback transport; it does not sign or encrypt the response.

Keep your existing provider and credentials, set `response_mode = "form_post"`
on the client, then use this UI setup. Here `client` has a registered
`redirect_uri` such as `https://app.example.com/callback`.

```{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 = ".*")
```

The wrapper includes `oauth_ui()` setup. Its module ID and client must match
the server's. For a callback path such as `/callback`, `uiPattern = ".*"`
lets Shiny send the POST to the wrapper. A callback at the app root also works.
`callback_path` defaults to the path in `redirect_uri`; keep both aligned.

### 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
)
```

Use your deployment's verified proxy address and public origin. The result
must still match the configured redirect origin and callback path. Do not
trust forwarded headers from arbitrary clients.

## JWT-secured authorization response mode (JARM)

JARM protects the authorization response with a signature. The provider
returns a JWT, and shinyOAuth verifies its signature, issuer, audience, and
expiry before processing the callback fields. If encryption is configured,
shinyOAuth decrypts the response before validating the signed contents.

```{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"
)
```

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")
)
```

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 binds tokens to a client key. The client signs a proof for each token
or API request, and the receiving server verifies it against the token binding.
An API enforcing DPoP requires both the access token and a proof from the
matching private key. Configure it when supported by the authorization server
and the API.

```{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, 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
  client = client
)
```

### Token binding requirements and validation

Supplying a DPoP key makes `dpop_require_access_token` default to `TRUE`:
the provider must return a DPoP access token. If binding data is visible,
its `cnf[["jkt"]]` key thumbprint must match. For opaque tokens with no visible
binding, enable `dpop_require_observed_cnf = TRUE` and arrange introspection
if your deployment needs to confirm that binding locally.

For certificate-bound tokens, the corresponding field is `cnf[["x5t#S256"]]`.
The package checks it against the configured certificate before protected
API and userinfo calls. A refreshed token needs fresh binding data from the
new token or its introspection response; the old certificate thumbprint is
not carried forward when the response omits it.

Binding data read from a JWT access token is observed payload data; shinyOAuth
does not independently verify that access token's signature. Introspection
can provide confirmation from the provider. The API must enforce the binding
too for a stolen token to be unusable without its key or certificate.

### DPoP in API requests

`perform_resource_req()` and `get_userinfo()` handle a DPoP nonce challenge
with one fresh-proof retry. Later requests to the same resource server can
reuse its nonce; token-server and resource-server nonces are kept separate.
Retries of eligible API requests generate fresh proofs.

DPoP nonces must follow RFC 9449's visible ASCII syntax. The package also
applies a local 4096-byte limit to bound proof and cache sizes; the RFC itself
sets no maximum length. Configure `options(shinyOAuth.dpop_nonce_max_bytes = 8192L)`
for a provider issuing larger nonces (supported range: 1–65536 bytes). A response
above the configured limit raises an explicit error without logging its nonce.

`resource_req()` only builds the request. A DPoP proof is tied to its HTTP
method and base URL, so do not change those after construction. Supply query
parameters through the helper's `query` argument; external URL modifiers can
decode reserved path characters and invalidate the proof. Use
`perform_resource_req()` to manage nonce retries.

## Signature and encryption support

For outgoing private-key client assertions, JAR, and DPoP, signing supports
`RS256`, `RS384`, `ES256`, `ES384`, `ES512`, and both `EdDSA` and `Ed25519` with Ed25519 keys. Ed25519
accepts an OpenSSL private key or PEM; DPoP embeds only its public OKP JWK.
Algorithm inference and explicit choices remain constrained by provider metadata.
Secret-based assertions and JAR support `HS256`, `HS384`, and `HS512`.
RSA continues to default to RS256; choose `client_assertion_alg = "RS384"`
explicitly for a registration requiring it. RS384 uses SHA-384 with
RSASSA-PKCS1-v1_5 and requires an RSA key of at least 2048 bits, as specified in
[RFC 7518 section 3.3](https://www.rfc-editor.org/rfc/rfc7518.html#section-3.3).
RSA-PSS and Ed448 are not supported for outgoing signatures.
Incoming signature policies are separate; see [`oauth_provider()`](https://lukakoning.github.io/shinyOAuth/reference/oauth_provider.html)
and the `jarm_*` arguments in [`oauth_client()`](https://lukakoning.github.io/shinyOAuth/reference/oauth_client.html).

Request Object encryption and JARM decryption support `RSA-OAEP` with
`A128CBC-HS256`, `A192CBC-HS384`, or `A256CBC-HS512`. This does not imply
support for encrypted ID tokens or encrypted userinfo, which are rejected.

The R `openssl` API currently exposes neither OAEP digest selection nor RSA-PSS
padding options. Its GCM functions do not accept the additional authenticated
data needed to authenticate JWE protected headers. Consequently, `RSA-OAEP-256`,
`PS256/384/512`, and `A128/192/256GCM` JWE modes remain unsupported and fail
closed; provider metadata never causes a fallback to another algorithm. These
are backend interoperability limits, not interchangeable names for the supported
modes. See the [OpenSSL R API](https://jeroen.r-universe.dev/openssl/doc/manual.html).
