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. Replace placeholder domains, credentials, and key paths with your registered values. Each example shows the settings for the feature being discussed.
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. 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.
oauth_provider_oidc_discover() reads provider metadata
used by these features, including PAR support, JARM and DPoP algorithms,
and mTLS endpoint aliases:
The sections below show the extra settings you usually add on top of
your normal oauth_client() setup.
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:
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:
client@client_assertion_audience <- provider@issuer
client@client_assertion_typ <- "client-authentication+jwt"RFC7523bis
draft 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:
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.
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.
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.
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().
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. 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.
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. Other audiences, incorrect authorized parties,
and invalid signatures are rejected.
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.
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
)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:
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.
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.
If your provider uses dynamic client registration,
oauth_client_mtls_registration() can build the RFC 8705
registration metadata from the configured client.
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.
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.
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:
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.
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.
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:
resp <- perform_resource_req(
auth[["token"]],
"https://api.example.com/me",
# Lets shinyOAuth attach the DPoP proof and handle nonce challenges
client = client
)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.
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.
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. RSA-PSS and Ed448 are not supported for outgoing
signatures. Incoming signature policies are separate; see oauth_provider()
and the jarm_* arguments in oauth_client().
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.