Authorization Code + PKCE

Copy Markdown View Source

AttestoClient owns protocol mechanics: discovery validation, S256 PKCE, state and nonce correlation, code exchange, ID Token verification, refresh single-flight, revocation, and logout request construction. Your application still owns authorization, durable token persistence, and session policy.

For a browser frontend, use this server-side client as the backend for frontend described in RFC 10017. Keep OAuth tokens on the backend; protect the browser session with secure, HttpOnly cookies and CSRF defenses, and authenticate the backend as a confidential client. A browser session should end when its usable grant expires.

Supervision

The included transaction store and each refresh coordinator process are node-local. Start them under your supervisor:

children = [
  {AttestoClient.AuthorizationTransaction.Store.ETS,
   name: MyApp.OIDCTransactions, max_entries: 10_000},
  {AttestoClient.RefreshCoordinator, name: MyApp.OIDCRefreshes}
]

If callbacks can land on different nodes, implement AttestoClient.AuthorizationTransaction.Store over a shared database or cache. Its put_new/4 and take/2 operations must be atomic, and take/2 must delete before returning.

Login starts can allocate store capacity before authentication. Rate-limit the start endpoint per client and network/user signal, and monitor :capacity_exceeded rather than treating the configured bound as abuse protection by itself.

Single-flight refresh protection covers callers sharing one coordinator process. In a cluster, route each token-record key to one coordinator or place a distributed lock/serialization layer around refresh; independent coordinators cannot prevent cross-node reuse of the same refresh token.

Start authorization

store = {AttestoClient.AuthorizationTransaction.Store.ETS, MyApp.OIDCTransactions}

# Generate this once per initiating browser session and retain it only in the
# application's secure, HttpOnly session. Do not put it in the redirect URL.
browser_binding = application_browser_session_binding

{:ok, request} =
  AttestoClient.AuthorizationCode.start(store,
    issuer: "https://accounts.example.com",
    client_id: "my-client",
    browser_binding: browser_binding,
    redirect_uri: "https://app.example.com/oidc/callback",
    scopes: ["openid", "profile", "email"],
    id_token_alg: "RS256"
  )

# Redirect the user agent to request.url.

id_token_alg must be the exact algorithm registered for the client. It is checked against provider metadata and then used as the sole accepted ID Token algorithm. RFC 9864 Ed25519 and Ed448 identifiers are supported only with their matching issuer JWK curve; legacy EdDSA remains compatible with both. Detached at_hash and c_hash validation uses the verified key to distinguish Ed25519's SHA-512 profile from Ed448's SHAKE256 profile.

The browser binding is mandatory protocol correlation: a callback created in one browser session cannot establish a login in another. AttestoClient treats it as an opaque value and does not create, retain, or authorize the application session. A fresh random value with at least 256 bits of entropy retained in the server-side session (or protected by the framework's secure session mechanism) is a suitable binding.

HTTPS redirect URIs are required for web clients. HTTP is accepted only for loopback redirects (127.0.0.0/8, [::1], or localhost) used by native clients.

Plain OAuth, PAR and DPoP

Use protocol: :oauth for an authorization server that does not provide an OIDC login, including OID4VCI issuance. Discovery uses RFC 8414 metadata; openid, a nonce and an ID Token are not required. The callback returns id_token_claims: nil. State, browser binding, S256 PKCE and issuer checks still apply, and the selected protocol is pinned in the stored transaction.

Pass par: true to push the bound request to the advertised PAR endpoint before redirecting. :client_auth and :dpop apply to that request. The default par: :auto uses PAR when metadata requires it. haip: true and fapi?: true require PAR regardless of the metadata flag: an absent PAR endpoint fails locally, and par: false cannot override the profile. A failed PAR or a transaction that expires during PAR is removed. Successful PAR requires HTTP 201, and the returned expires_in reflects both the remaining transaction and PAR lifetimes.

When supplying dpop: at start, retain the same private key for the callback. Its thumbprint is sent as dpop_jkt; a different or missing key fails before code exchange, and a Bearer token response is rejected. Client authentication supports the existing secret and private-key methods and {:client_attestation, attestation, instance_key, options}.

Select exactly one of haip: true or fapi?: true; selecting both is an error. The selected profile is stored with the transaction and cannot be downgraded by callback flags. Both require an authenticated PAR request and token exchange, a retained DPoP private key, and the authorization response's iss. These requirements are checked even when metadata omits their flags. Missing authentication or signing material fails before discovery; a changed method, client identifier, authentication key, or DPoP key fails before token HTTP.

FAPI uses the supported private_key_jwt method with a FAPI-compatible algorithm and key. Set alg: "PS256" explicitly for RSA client authentication; its generic inferred default is RS256. The DPoP key's inferred algorithm must also satisfy the profile, so an EC P-256 or Ed25519 key is suitable. This API does not establish an mTLS client/certificate binding; TLS request options or a flag cannot substitute for the required DPoP key.

HAIP supports authenticated methods according to the issuer's ecosystem policy, including client attestation. Retain the client subject and instance key through callback and refresh, with sub matching client_id, cnf matching that key, and the PoP audience matching the authorization server issuer. A refreshed challenge may be supplied without changing that identity. Renewed attestations may change their timestamps, JWT identifier, signature, provider certificate or optional iss claim while retaining the client subject, instance key and PoP algorithm. HAIP's Appendix E format requires an x5c header. This client accepts 1–8 base64 DER certificates, each at most 64 KiB decoded. The client checks complete DER certificate structure before HTTP. The authorization server still verifies attester signatures, certificate trust and attestation validity; local consistency checks do not authenticate the provider. Generic OAuth and OIDC transactions retain their existing authentication choices.

HAIP authorization requests require a nonempty credential-type scope. The application must choose a scope mapped by the issuer to the requested credential configuration. For FAPI OIDC, an omitted id_token_alg selects an advertised compliant algorithm in the order ES256, PS256, Ed25519, EdDSA; an explicitly incompatible algorithm fails locally. Verification also checks the selected issuer key's FAPI algorithm and strength rules.

Issued token sets retain local profile, client_auth_binding, client_id, issuer and id_token_alg provenance alongside dpop_jkt. These values are derived from the trusted transaction rather than token-endpoint JSON. Retain the full token set for later profile-aware operations.

When upgrading a shared transaction store, pending generic transactions that lack the new profile fields remain supported. Pending protected transactions with the old issuer-protection flag but no explicit profile must be restarted; the client rejects them locally rather than inferring an authentication policy.

Extra authorization parameters cannot override bound protocol fields or client authentication. This includes bracket aliases such as state[value] and client_assertion[], which web frameworks decode under the protected root. The original query strings of discovered authorization, PAR and token endpoints are checked too: protected exact names, bracket aliases, and duplicate decoded roots are rejected before merging or making a request. Benign fixed endpoint parameters remain supported.

See the Credential wallet guide for a complete plain OAuth/PAR integration example.

Handle the callback

Pass the original callback URI or raw URL-encoded query whenever it is available. The client rejects repeated names before building a map, including equivalent percent-encoded names such as code and co%64e:

{:ok, completed} =
  AttestoClient.AuthorizationCode.callback(store, conn.query_string,
    browser_binding: browser_binding_from_secure_session,
    client_auth: {:private_key_jwt, client_private_jwk},
    timeout: 10_000
  )

claims = completed.id_token_claims
tokens = completed.tokens

A string-keyed parameter map remains accepted for framework integrations. A map cannot reveal duplicate names that the framework already collapsed, so the host must reject duplicates before parsing when the original URI or body is not passed to callback/3.

State is consumed before the token request, including for provider errors and invalid responses. An ambiguous encoded response is rejected before state is consumed.

A timeout means the token endpoint outcome is unknown. The code and transaction have already been consumed and must not be retried.

Successful verification establishes protocol facts such as issuer, audience, nonce, signature, and time validity. It does not decide that the subject may access your application. Apply authorization before creating a session.

When several providers share a client, prefer a distinct registered redirect URI for each provider. This prevents provider mix-up when an older provider does not return the authorization-response iss parameter.

Refresh rotation

Use a stable, non-secret key for the token record. Concurrent calls using the same key share one request and result:

{:ok, result} =
  AttestoClient.Token.refresh(MyApp.OIDCRefreshes, token_record_key, tokens,
    token_endpoint: metadata["token_endpoint"],
    issuer: metadata["issuer"],
    metadata: metadata,
    client_id: "my-client",
    client_auth: {:private_key_jwt, client_private_jwk},
    subject: verified_subject,
    id_token_alg: "RS256",
    timeout: 10_000
  )

Compare-and-swap the stored token set with result.tokens against the prior record version or refresh token, rejecting stale results. The coordinator does not retain it after returning. If the response contains an ID Token, result.id_token_claims contains its verified claims; issuer, audience, subject, algorithm, time, and any at_hash are checked.

Refresh uses the selected algorithm retained in tokens.id_token_alg, so FAPI ES256 flows do not need to repeat id_token_alg on refresh. Persist this field with the token set. A conflicting explicit option returns {:error, :id_token_alg_mismatch} before discovery or token HTTP. Successful refreshes retain it even when no ID Token is returned, and shared results must match each caller's algorithm. Legacy token sets without a retained algorithm use the explicit option or the previous default (PS256 for FAPI, RS256 otherwise), then retain that selection for later refreshes.

For DPoP tokens, also pass dpop: original_private_key. A missing signing key, public-only key, or a key that differs from the locally recorded tokens.dpop_jkt fails before discovery or token HTTP. A requested or previous DPoP token cannot accept a Bearer response; token-type comparison is case insensitive. Successful refresh carries the local thumbprint across rotation, including nonce retries. Persist it with the token set, rather than deriving it from untrusted token-endpoint JSON. A coalesced result is checked against each caller's key; another caller's key cannot be adopted under the same record key.

Legacy or manually assembled DPoP token sets without dpop_jkt still require a signing key. Their historical key continuity cannot be checked; after a successful DPoP refresh the supplied key is recorded for subsequent refreshes.

When the provider implements refresh-expiration draft03, result.tokens.refresh_token_timeout and authorization_expires_in carry validated, literal durations in seconds. Record the response time when deriving deadlines; a retry must not restart a previously recorded grant deadline. Absent fields stay nil, and credentials may expire earlier through revocation.

Revocation and logout

:ok =
  AttestoClient.Token.revoke(tokens.refresh_token,
    revocation_endpoint: metadata["revocation_endpoint"],
    issuer: metadata["issuer"],
    client_id: "my-client",
    client_auth: {:private_key_jwt, client_private_jwk},
    token_type_hint: "refresh_token"
  )

{:ok, logout_url} =
  AttestoClient.Logout.url(
    issuer: metadata["issuer"],
    metadata: metadata,
    id_token_hint: tokens.id_token,
    client_id: "my-client",
    post_logout_redirect_uri: "https://app.example.com/logged-out",
    state: application_generated_logout_state
  )

The application correlates logout state and decides when to terminate its local session. Revocation and provider logout do not substitute for local policy.