Audit logging and hooks

Overview

shinyOAuth emits structured audit events for authorization requests, callbacks, token operations, and changes to the authenticated session. Configure shinyOAuth.audit_hook with an R function to receive these events as named lists and send them to your application’s logging system.

Receiving audit events

Put this near the top of app.R, before creating the provider and client:

options(shinyOAuth.audit_hook = function(event) {
  cat(sprintf("[shinyOAuth] %s | %s\n", event[["type"]], event[["trace_id"]]))
  str(event)
})

Run your app and try signing in. You will see event names such as audit_redirect_issued and audit_login_success. The trace_id connects related events from the same operation or login attempt. Several events can describe one failure; they are not necessarily separate failed logins. For an interactive login, the same trace_id follows redirect preparation, callback validation, token exchange, and the login result.

To stop receiving events, set options(shinyOAuth.audit_hook = NULL). Keep hooks fast and avoid throwing errors. A hook that waits for a slow log service can delay the login operation that called it.

Authentication outcomes

Use audit_authenticated_changed to track the module’s final authentication state and audit_session_cleared to record why a session ended. Events such as audit_token_exchange and audit_userinfo report individual operations; subsequent validation can still fail. Callback failures include phase and error_class fields to help identify the failed check. The event catalog below documents the available names and fields.

Event structure

Audit events have type, trace_id, and a timestamp from Sys.time(), plus fields specific to the operation. The hook also receives error events such as error, http_error, and transport_error; their fields are listed at the end of the catalog. Do not assume every field exists on every event.

When a Shiny session is available, shiny_session contains:

The session context is a JSON-friendly list suitable for jsonlite::toJSON(); the raw Shiny session[["request"]] object is not included. If shinyOAuth.audit_include_raw_session_token = TRUE, the raw session token is available as shiny_session[["token"]] in native hook events.

Fields ending in _digest allow matching without recording the original token or identifier. The package’s trace_id is separate from OpenTelemetry trace/span IDs; OTel exports it as shinyoauth.trace_id.

HTTP context and redaction

By default, the HTTP summary omits paths, query strings, headers, and client addresses. The raw Shiny session token is also omitted. To omit HTTP context entirely:

options(shinyOAuth.audit_include_http = FALSE)

With this setting, shiny_session[["http"]] is NULL.

Outbound URLs retain only scheme and authority by default. To include safe route names in audit and OTel URLs and incoming HTTP context, configure shinyOAuth.telemetry_path_scrubber as a function of the path returning a safe absolute path (for example /users/:id), or NULL to omit it. Use an allowlist or replace all identifying path segments; the function should be idempotent. Scrubber errors and invalid results omit the path. Scrubber input is limited to 2048 bytes; exported paths to 512 bytes. Method, host, and scheme are limited to 32, 255, and 16 bytes. Control and Unicode format characters are removed.

For local debugging, shinyOAuth.audit_redact_http = FALSE includes raw request details, and shinyOAuth.audit_include_raw_session_token = TRUE includes the raw session token. These can expose credentials and personal data; keep the defaults for production logs. Redaction does not replace your logging system’s access and retention controls.

Audit events from asynchronous workers

With oauth_module_server(async = TRUE), the hook is passed to background work too. Use a hook that can run in a separate R process. Existing database connections or open file handles cannot be safely carried into a worker; create connections there or use a logging service designed for multiple writers. Appending to a global R list inside a worker only changes that worker’s copy.

Worker failures may include mirai_error_type: mirai_error, mirai_timeout, mirai_connection_reset, or mirai_interrupt. This helps distinguish an R error from a timeout, crashed worker, or cancellation. It can be NA for a failure that is not specific to mirai.

Digest keys across R processes

Digests use HMAC-SHA256 with a random process key by default. The module shares that key with its async workers. To match digests across separate app processes or restarts, configure the same secret key everywhere:

audit_digest_key <- Sys.getenv("AUDIT_DIGEST_KEY", unset = NA_character_)
if (is.na(audit_digest_key) || !nzchar(audit_digest_key)) {
  stop("AUDIT_DIGEST_KEY must be configured before the app starts")
}
options(shinyOAuth.audit_digest_key = audit_digest_key)

Use a string or raw vector containing at least 32 bytes, generated from at least 32 cryptographically random bytes and stored as a deployment secret. Invalid or short configured keys cause an error. FALSE selects unkeyed SHA-256 for compatibility, but makes low-entropy identifiers easier to guess.

For exporting events and timings through OpenTelemetry, see OpenTelemetry.

Event catalog

This is a lookup reference. Start with the event name in your logs; fields are included when relevant and available.

Authorization redirect issuance

Event: audit_redirect_issued

  • When: after prepare_call() builds the authorization URL

  • Context:

    • provider, issuer
    • client_id_digest
    • state_digest
    • browser_token_digest
    • pkce_method (e.g., S256, plain, or NA)
    • par_used (logical)
    • request_object_used (logical)
    • nonce_present (logical)
    • scopes_count
    • redirect_uri

Callback query rejected

Event: audit_callback_query_rejected

  • When: the callback query parameters fail validation (e.g., too large)
  • Context: provider, issuer, client_id_digest, error_class
  • For form_post bridge query rejections, context also includes phase, reason, and handle_digest.

Callback routing rejected

Event: audit_callback_routing_rejected

  • When: a multi-provider registry rejects a callback before selecting a client.
  • Context: phase (callback_registry_routing), reason, status (error), and sanitized HTTP metadata in shiny_session.http.
  • Reasons: route_unavailable, route_unregistered, unexpected_transport, issuer_missing, and issuer_unrecognized.
  • The shinyOAuth.callback.route span records the same phase and reason with error status. No provider identity is assigned from unvalidated issuer input.
  • Parser errors and later client-specific validation retain their existing diagnostics; they do not also emit this routing event.

Callback issuer validation

Event: audit_callback_iss_missing

  • When: enforce_callback_issuer = TRUE and the callback omits the RFC 9207 iss parameter
  • Context: provider, expected_issuer, client_id_digest, error_class

Event: audit_callback_iss_mismatch

  • When: the callback includes an iss query parameter (per RFC 9207) that does not match the provider’s expected issuer
  • Context: provider, expected_issuer, callback_issuer, client_id_digest, error_class

Event: audit_callback_iss_validation_failed

  • When: callback issuer validation fails before token exchange for a reason other than the dedicated missing/mismatch cases above
  • Context: provider, expected_issuer, callback_issuer (when present), client_id_digest, error_class

Callback received

Event: audit_callback_received

  • When: a callback has passed encrypted-state validation; later callback checks may still fail
  • Context: provider, issuer, client_id_digest, code_digest, state_digest, browser_token_digest
  • Notes: callbacks that fail before payload validation do not emit this event

Callback validation

Callback validation covers both the sealed-state checks and the later checks of the values tied to that state, such as the browser token, PKCE code verifier, and nonce. Each stage emits either a success event or a failure event.

Event: audit_callback_validation_success

  • When: the callback has passed state, browser-binding, and single-use checks
  • Context: provider, issuer, client_id_digest, state_digest

Event: audit_callback_validation_failed

  • When: a validation step fails prior to token exchange
  • Context: provider, issuer, client_id_digest, state_digest, phase, error_class (+ browser_token_digest when phase is browser_token_validation)
  • Phases include: payload_validation, browser_token_validation, pkce_verifier_validation, nonce_validation, form_post_request_validation, form_post_callback_lookup, form_post_callback_validation
  • handle_digest is included when a form_post callback handle is missing, expired, or already consumed.
  • Note: Failures related to state store access (lookup/removal) are reported as their own events (see below) rather than using the callback_validation_failed event.

State store access

These events identify failures when reading or removing a pending login from the state store.

Event: audit_state_store_lookup_failed

  • When: retrieving the single-use state entry from the configured state_store fails (missing, malformed, or underlying cache error)
  • Context: provider, issuer, client_id_digest, state_digest, error_class, phase (state_store_lookup or state_store_atomic_take)
  • Notes: The flow aborts with an invalid state error. The state_store_atomic_take phase applies when using a store with an atomic [["take"]]() method.

Event: audit_state_store_removal_failed

  • When: removal of the single-use state entry (enforcing one-time use) fails
  • Context: provider, issuer, client_id_digest, state_digest, error_class, phase (state_store_removal)
  • Notes: A failure to remove also aborts the flow with an invalid state error; the event is emitted best-effort and will never itself throw.

Digest differences: For audit_callback_validation_failed during payload decryption (phase = "payload_validation") the state_digest is computed from the encrypted payload (plaintext not yet available). For state store events the digest reflects the plaintext state string.

Token exchange

Event: audit_token_exchange

  • When: authorization code successfully exchanged for tokens
  • Context: provider, issuer, client_id_digest, code_digest, used_pkce, received_id_token, received_refresh_token, expires_in_synthesized
  • expires_in_synthesized (logical): TRUE when the token response did not include a usable expires_in value and the package used the configured fallback token lifetime

Event: audit_token_exchange_error

  • When: token exchange fails
  • Context: provider, issuer, client_id_digest, code_digest, error_class

Detailed sender-constraint diagnostics such as DPoP token-type inference, DPoP nonce retries, and mTLS endpoint-alias selection are emitted on the OpenTelemetry spans documented in the OpenTelemetry vignette rather than on the high-level audit events.

Token introspection

Event: audit_token_introspection

  • When: introspect_token() reaches a final result (for example during login or refresh when introspect = TRUE)
  • Context:
    • provider, issuer, client_id_digest
    • which (“access” or “refresh”)
    • supported (logical), active (logical|NA), status
    • sub_digest, introspected_client_id_digest, scope_digest (when available)
  • status values include "ok", "introspection_unsupported", "missing_token", "body_too_large", "invalid_json", "missing_active", "invalid_active", and "http_<code>"

Login result

Event: audit_login_success

  • When: callback handling has completed the configured token, UserInfo, and introspection checks and is ready to return the OAuthToken
  • Context: provider, issuer, client_id_digest, sub_digest, sub_source, refresh_token_present, expires_at

sub_source indicates where sub_digest was derived from:

  • userinfo: subject came from the userinfo response
  • id_token: subject came from an ID token that was validated (signature + claims)
  • id_token_unverified: subject came from an ID token payload parse when ID token validation was not performed

Event: audit_login_failed

  • When: login failure during callback handling in the Shiny module
  • Context: provider, issuer, client_id_digest, phase, error_class, mirai_error_type
  • phase currently includes:
    • sync_token_exchange
    • async_token_exchange
    • async_payload_validation
    • async_state_store_lookup
  • mirai_error_type is only present on async failure paths

Logout and session clears

Event: audit_logout

  • When: auth[["logout"]]() is called on the module
  • Context: provider, issuer, client_id_digest, reason (default manual_logout)

Event: audit_connection_disconnected

Emitted once for each retained connection removed locally, after bounded remote cleanup. It is emitted even when revocation is disabled or fails. Context includes owner_digest, connection_id_digest, retention, reason, local_outcome, revoke_requested, remote_refresh_outcome, and remote_access_outcome. Reasons are disconnect, disconnect_all, logout, or session_end. Identifiers use the configured audit digest policy; credentials and raw identities are excluded. Remote outcomes describe the revocation attempt, not proof that the authorization server previously recognized a token.

Event: audit_connections_disconnected

Summarizes a completed local bulk removal, including an empty batch, with owner_digest, connection_count, retention, reason, local_outcome, and revoke_requested. Per-connection events describe remote outcomes. This event also covers logout and removal of session-only connections at session end; ending a Shiny session does not remove browser- or account-retained connections. The batch and per-connection events share a trace ID and are available to both the native audit hook and OpenTelemetry. Existing module logout events remain.

Event: audit_session_cleared

  • When: the module clears the token reactively
  • Context: provider, issuer, client_id_digest, reason, error_class, mirai_error_type
  • Reasons include: refresh_failed_async, refresh_failed_sync, reauth_window, token_expired
  • Note: error_class is present on refresh failure reasons (refresh_failed_async, refresh_failed_sync) but absent for reauth_window and token_expired; mirai_error_type is only present for async refresh-failure clears

Token revocation

Event: audit_token_revocation

  • When: revoke_token() reaches a final outcome (including early unsupported or missing_token returns) during logout or session end
  • Context:
    • provider, issuer, client_id_digest
    • which (“access” or “refresh”)
    • supported (logical), revoked (logical|NA), status
  • status values include "ok", "revocation_unsupported", "missing_token", and "http_<code>"

Refresh failures while keeping the session (indefinite sessions)

Event: audit_refresh_failed_but_kept_session

  • When: a token refresh attempt fails but the module is configured not to clear the session (i.e., indefinite_session = TRUE in oauth_module_server())
  • Context: provider, issuer, client_id_digest, reason (refresh_failed_async|refresh_failed_sync), kept_token (TRUE), error_class, mirai_error_type
  • mirai_error_type is only present on async refresh failures

Browser cookie/WebCrypto error

Invalid browser token

Event: audit_invalid_browser_token

  • When: the module receives an invalid shinyOAuth_sid value from the browser and requests regeneration
  • Context: provider, issuer, client_id_digest, reason, length

Token refresh

Event: audit_token_refresh

  • When: refresh_token() successfully refreshes the access token
  • Context: provider, issuer, client_id_digest, refresh_token_rotated, new_expires_at, expires_in_synthesized
  • expires_in_synthesized (logical): TRUE when the refresh response did not include a usable expires_in value and the package used the configured fallback token lifetime

Userinfo fetch

Event: audit_userinfo

  • When: get_userinfo() is called to retrieve user information (emitted on success and various failure modes)
  • Context: provider, issuer, client_id_digest, sub_digest, status
  • status values:
    • "ok" – userinfo successfully parsed
    • "parse_error" – response could not be parsed as JSON or JWT. Additional fields: http_status, url, content_type, body_digest
    • "userinfo_missing_sub" – OIDC userinfo response was parsed but omitted the required sub claim
    • "userinfo_not_jwt" – signed JWT required but response was not application/jwt. Additional fields: content_type
    • "userinfo_jwt_encrypted" – userinfo response was a JWE, which ‘shinyOAuth’ does not decrypt
    • "userinfo_jwt_header_parse_failed" – JWT header could not be parsed
    • "userinfo_jwt_header_invalid" – JWT header parsed but was malformed or structurally invalid
    • "userinfo_jwt_typ_invalid" – JWT header typ did not indicate a JWT
    • "userinfo_jwt_unsigned" – JWT uses alg=none. Additional fields: jwt_alg
    • "userinfo_jwt_alg_rejected" – JWT algorithm not in provider’s allowed asymmetric algorithms. Additional fields: jwt_alg
    • "userinfo_jwt_no_issuer" – provider issuer not configured for JWKS verification
    • "userinfo_jwt_jwks_fetch_failed" – JWKS fetch failed during signature verification
    • "userinfo_jwt_signature_invalid" – signature verification failed against candidate JWKS keys
    • "userinfo_jwt_no_matching_key" – provider JWKS had no compatible key for the JWT
    • "userinfo_jwt_payload_parse_failed" – JWT payload could not be parsed as JSON
    • "userinfo_jwt_missing_sub", "userinfo_jwt_missing_iss", "userinfo_jwt_missing_aud" – signed JWT omitted a required claim
    • "userinfo_jwt_iss_mismatch", "userinfo_jwt_aud_mismatch" – signed JWT claims did not match the configured issuer/client
    • "userinfo_jwt_missing_required_temporal_claims" – signed JWT omitted required temporal claims such as exp or iat
    • "userinfo_jwt_invalid_exp", "userinfo_jwt_invalid_iat", "userinfo_jwt_invalid_nbf" – temporal claim was present but not a single usable numeric value
    • "userinfo_jwt_expired", "userinfo_jwt_iat_future", "userinfo_jwt_nbf_future" – temporal claim failed time validation

State parsing failures

State parsing failures occur while decoding and validating the encrypted wrapper prior to extracting the logical state value, and also when deriving a cache key from a malformed logical state string.

Event: audit_state_parse_failure

  • When: the encrypted state wrapper or its components fail validation/decoding, or cache-key derivation receives an invalid logical state string
  • Context: includes phase (decrypt or cache_key), a reason code, and either token_digest (phase = decrypt) or state_digest (phase = cache_key), plus any additional details (such as lengths). Emitted best-effort from parsing utilities and never interferes with control flow.

Error response state consumption

Provider error callbacks still need valid login state and browser binding. The events below report the one-time state consumption, not the outcome of all callback checks. Browser mismatches use audit_callback_validation_failed with phase = "browser_token_validation".

Event: audit_error_state_consumed

  • When: state from an error response is successfully consumed
  • Context: provider, issuer, client_id_digest, state_digest

Event: audit_error_state_consumption_failed

  • When: consumption of state from an error response fails
  • Context: provider, issuer, client_id_digest, state_digest, error_class, error_message

Digest note: when the callback state can be decrypted, these events use the logical plaintext state digest so they correlate with audit_redirect_issued and the normal callback validation/store events. If decryption fails, the digest falls back to the encrypted callback payload because the logical state is unknown.

Module/session lifecycle

Event: audit_session_started

  • When: the authentication module (oauth_module_server()) is initialized for a Shiny session
  • Context: module_id, ns_prefix, client_provider, client_issuer, client_id_digest, plus the standard shiny_session context described above

Event: audit_session_ended

  • When: a Shiny session ends (always emitted by onSessionEnded, regardless of configuration)
  • Context: provider, issuer, client_id_digest, was_authenticated

Event: audit_session_ended_revoke

  • When: a Shiny session ends with revoke_on_session_end = TRUE and a token was present
  • Context: provider, issuer, client_id_digest; the actual revocation attempt is logged separately as audit_token_revocation events

Authentication state changes

Event: audit_authenticated_changed

  • When: the [["authenticated"]] reactive value changes (TRUE ↔︎ FALSE)
  • Context: provider, issuer, client_id_digest, authenticated, previous_authenticated, reason
  • Reasons include: login (when becoming authenticated), or the error code/state that caused de-authentication (e.g., token_expired, logged_out, token_cleared)

Error events

In addition to the audit_* events above, the hook also receives error events emitted just before the package raises an R error condition. These let you log failures to the same sink as audit events.

Event: error

  • When: a package error is raised during package processing (state validation, PKCE, token, ID token, userinfo, configuration, input, parse errors)
  • Fields:
    • type ("error"), trace_id, message,
    • Plus any context fields from the call site (typically provider, issuer, client_id_digest, phase, error_class)

Event: http_error

  • When: an outbound HTTP request to a provider endpoint returns a non-success status
  • Fields:
    • type ("http_error"), trace_id, message
    • status: HTTP status code (integer, or NA if unavailable)
    • url: the request scheme and authority, plus an approved path only when a path scrubber is configured
    • body_digest: HMAC-SHA-256 hex digest of the response body using the configured or per-process audit digest key (for correlation without leaking content)
    • oauth_error, oauth_error_uri: RFC 6749 §5.2 structured error fields extracted from JSON error responses (e.g., from the token endpoint)
    • oauth_error_description: included only when options(shinyOAuth.expose_error_body = TRUE) is enabled for debugging, because provider-controlled text can contain request-specific details
    • Plus any context fields from the call site

Event: transport_error

  • When: an outbound HTTP request fails before receiving a response (DNS failure, timeout, connection reset, etc.)
  • Fields:
    • type ("transport_error"), trace_id, message
    • Plus any context fields from the call site