NURLNURL registrynurl-lang.org →

← oauth

oauth 0.1.0 API

guard.nu

oauth/guard.nu — the resource-server side: a route that knows who called it.

The client half of this package gets tokens; this half spends them. Wrap a handler and it only ever runs for a request that carried a valid Authorization: Bearer <JWT>, with the caller's verified identity handed straight in:

( http_app_get a /me ( with_oidc_bearer p pol \ HttpRequest req OidcIdentity id → HttpResponse { ^ ( response_text 200 ( string_data ( oidc_identity_describe id ) ) ) } ) )

Everything a bearer token can be wrong about — no token, a token from another issuer, for another audience, expired, signed with a key the provider never published, signed with alg: none — becomes a 401 with the RFC 6750 §3 challenge naming the reason. A token that is valid but does not carry the scope the route needs is a 403 insufficient_scope (with_oidc_scope), because that is a different answer: re- authenticating will not help, asking for more scope will.

id is BORROWED by the handler — the middleware frees it (and the claims behind it) once the handler returns.

The wrapper closure lives as long as the app that routes to it, the same as with_jwt_* / with_cors_default: build it once at startup.

API

@ _oidc_safe_param String out s value → v

Append value as the inside of an RFC 7235 quoted-string, keeping only bytes that are legal there and capping the length.

This is load-bearing, not hygiene: the description we render comes from oidc_provider_last_error, and some of what lands there is quoted from the TOKEN — an attacker-supplied kid, a provider's error_description. A CR or LF echoed into a response header is response splitting; a " ends the parameter early. Neither reaches the wire.

@ _oidc_unauthorized s code s desc → HttpResponse

401 with a Bearer challenge. code is the RFC 6750 error token, empty for a missing credential (§3.1: no error code when none was offered).

@ _oidc_forbidden s scope → HttpResponse

403: the caller is who they say, and still may not do this.

@ oidc_request_identity * OidcProvider p * OidcPolicy pol HttpRequest req → !OidcIdentity OauthErr

Verify the request's bearer token directly — for a handler that wants the identity without being wrapped, or for a protocol other than HTTP routing (a WebSocket upgrade, an MCP session).

@ with_oidc_bearer * OidcProvider p * OidcPolicy pol ( @ HttpResponse HttpRequest OidcIdentity ) inner → ( @ HttpResponse HttpRequest )

Only run inner for an authenticated caller.

@ with_oidc_scope * OidcProvider p * OidcPolicy pol s scope ( @ HttpResponse HttpRequest OidcIdentity ) inner → ( @ HttpResponse HttpRequest )

Authenticated AND carrying scope.


pkce.nu

oauth/pkce.nu — PKCE, state and nonce (RFC 7636, OIDC core §3.1.2.1).

Three one-use random values, each closing a different hole in the authorization-code flow:

verifier/challenge binds the code to THIS client instance. The authorization request carries only SHA-256(verifier); the token request carries the verifier itself. A code stolen in transit (a redirect log, a malicious app claiming the same URI scheme) is worthless without it. Mandatory for public clients, and recommended for every client by OAuth 2.1. state binds the callback to the request the user started here, which is what defeats login-CSRF. nonce binds the ID token to that same request — the check lives in claims.nu (oidc_policy_set_nonce), the value is minted here.

All three come from the OS CSPRNG (std/random.nu → nurl_rand_fill) and are base64url with no padding, so they need no further escaping in a URL and are the RFC 7636 "unreserved" alphabet by construction.

: Pkce pk ( pkce_new ) … authorize with ( string_data . pk challenge ) … … exchange with ( string_data . pk verifier ) … ( pkce_free pk )

API

: Pkce

: Pkce {
    String verifier  // the secret, sent only to the token endpoint
    String challenge  // base64url(SHA-256(verifier)), sent in the URL
    String method  // always "S256"; "plain" is not offered
}

@ oauth_random_token i nbytes → String

nbytes of CSPRNG output, base64url-unpadded. 32 bytes → 43 chars, the RFC 7636 minimum verifier length and 256 bits of entropy.

@ oauth_state_new → String

@ oauth_nonce_new → String

@ pkce_challenge_for s verifier → String

base64url(SHA-256(ASCII(verifier))) — the S256 transform.

@ pkce_new → Pkce

@ pkce_free Pkce pk → v


provider.nu

oauth/provider.nu — the identity provider: discovery, keys, verdict.

Everything a relying party needs to know about a provider is published by the provider itself. OidcProvider is that knowledge, fetched once and kept:

( oidc_provider_discover issuer ) → ! *OidcProvider OauthErr GET <issuer>/.well-known/openid-configuration (RFC 8414 §3), and CHECK that the document's own issuer is the one we asked for — otherwise a redirect to an attacker's metadata would silently repoint the token and userinfo endpoints.

( oidc_verify_id_token p pol token ) → ! OidcIdentity OauthErr The whole answer to "who is this": parse the JOSE header, find the key it names in the provider's JWKS (fetching the set on first use, and re-fetching when a kid we have never seen shows up — that is key rotation, and it must not need a restart), verify the signature, apply the claim policy, and hand back the identity with the full claim set attached.

The JWKS re-fetch is rate-limited (oidc_provider_set_min_refetch, default 300 s): an unknown kid is also what a flood of forged tokens looks like, and a verifier that fetches on every one of them is a denial-of-service amplifier pointed at its own provider.

A failure that has detail the enum cannot carry — the HTTP status, the claim that was wrong, the provider's own error_description — leaves it in oidc_provider_last_error.

THREADING: an *OidcProvider owns one HTTP client and one mutable key cache, and takes no lock. One provider per thread, or one thread that owns it — sharing it across a server's worker pool is a data race, not a slow path. (An *OidcPolicy is read-only once built and IS safe to share; so is a verified OidcIdentity, which is a value.)

API

: OidcProvider

: OidcProvider {
    String issuer
    String authorization_endpoint
    String token_endpoint
    String userinfo_endpoint
    String jwks_uri
    String end_session_endpoint
    String device_authorization_endpoint
    String introspection_endpoint
    String revocation_endpoint
    ( Vec JwkKey ) keys
    i keys_at  // epoch seconds of the last JWKS fetch (0 = never)
    i min_refetch  // seconds that must pass before another JWKS fetch
    b discovered
    String last_error
    * HttpClient http
}

@ oidc_provider_new s issuer → *OidcProvider

@ oidc_provider_free * OidcProvider p → v

@ oidc_provider_http * OidcProvider p → *HttpClient

The HTTP client every request goes through — exposed so a caller can set a timeout, turn off certificate verification for a test provider, or pin HTTP/3.

@ oidc_provider_last_error * OidcProvider p → s

@ oidc_provider_set_min_refetch * OidcProvider p i secs → v

@ _oidc_err * OidcProvider p s msg → v

@ _oidc_err2 * OidcProvider p s msg s detail → v

@ _oidc_err_status * OidcProvider p s what i status → v

@ oidc_provider_set_jwks_uri * OidcProvider p s uri → v

@ oidc_provider_set_token_endpoint * OidcProvider p s uri → v

@ oidc_provider_set_authorization_endpoint * OidcProvider p s uri → v

@ oidc_provider_set_userinfo_endpoint * OidcProvider p s uri → v

@ oidc_provider_set_jwks * OidcProvider p s jwks_json → b

Load a key set the caller already has (a pinned JWKS, an offline verifier, a test). Replaces whatever was cached.

@ oidc_provider_key_count * OidcProvider p → i

@ oidc_discovery_url s issuer → String

<issuer>/.well-known/openid-configuration, with exactly one slash.

@ oidc_discover * OidcProvider p → ?OauthErr

Fetch the metadata document and adopt its endpoints. None = success.

@ oidc_provider_discover s issuer → !*OidcProvider OauthErr

@ oidc_fetch_jwks * OidcProvider p → ?OauthErr

Fetch jwks_uri and replace the cached set. None = success.

@ oidc_provider_ensure_key * OidcProvider p s kid s alg → i

Index of the key for (kid, alg), fetching or re-fetching the JWKS when that is what it takes. -1 when the provider has no such key.

@ oidc_verify_token_at * OidcProvider p * OidcPolicy pol s token i now → !OidcIdentity OauthErr

The whole check, at an explicit now (epoch seconds).

@ oidc_verify_token * OidcProvider p * OidcPolicy pol s token → !OidcIdentity OauthErr

@ oidc_verify_id_token * OidcProvider p * OidcPolicy pol s token → !OidcIdentity OauthErr

Named for the caller's intent — the same check either way. An ID token is verified against the client id in aud; an access token issued as a JWT (RFC 9068) against the resource server's own audience.

@ oidc_verify_access_token * OidcProvider p * OidcPolicy pol s token → !OidcIdentity OauthErr


jwk.nu

oauth/jwk.nu — JSON Web Key and JWK Set (RFC 7517 / RFC 7518 §6).

A provider publishes its verification keys as a JWKS document:

{ "keys": [ { "kty":"RSA", "kid":"a1", "alg":"RS256", "n":"<base64url>", "e":"AQAB" }, … ] }

This module turns that into ( Vec JwkKey ) — the parameters already base64url-decoded into byte vectors, ready to hand to the verifier — and picks the key a token's kid/alg names.

( jwks_parse text ) → ( Vec JwkKey ) (empty on garbage) ( jwks_from_json doc ) → ( Vec JwkKey ) ( jwks_free ks ) → v ( jwks_select ks kid alg ) → i index, or -1 ( jwk_ec_point jk ) → ( Vec u ) 0x04‖X‖Y ( jwk_thumbprint jk ) → String RFC 7638, base64url

A JwkKey is a plain value struct: the strings and vectors are owned by the vector that holds it, so vec_data + . data k hands out a BORROW that must not be freed — only jwks_free frees.

Key selection follows RFC 7515 §4.1.4 and the OIDC core rules: a kid in the token header must match a kid in the set; a key that declares alg must agree with the header's; a key that declares use must say sig; and the key type must be the one the algorithm family needs (RS/PS → RSA, ES → EC with the right curve, EdDSA → OKP/Ed25519, HS → oct). A set with exactly one usable key is accepted even when the token carries no kid — the common single-key provider.

API

: JwkKey

: JwkKey {
    String kty  // "RSA" · "EC" · "OKP" · "oct"
    String kid
    String alg  // may be empty — a key that names no algorithm
    String crv  // "P-256" · "P-384" · "Ed25519"
    String usage  // the `use` member: "sig" · "enc" · empty
    ( Vec u ) n  // RSA modulus (big-endian)
    ( Vec u ) e  // RSA public exponent (big-endian)
    ( Vec u ) x  // EC x-coordinate · OKP public key
    ( Vec u ) y  // EC y-coordinate
    ( Vec u ) oct  // the `k` member: a symmetric secret (HS*)
}

@ jwk_free JwkKey jk → v

@ jwks_free ( Vec JwkKey ) ks → v

@ jwk_push_json ( Vec JwkKey ) out Json j → b

Append one JWK object to out. Returns F (and appends nothing) when the node is not an object with a kty.

@ jwks_from_json Json doc → ( Vec JwkKey )

The keys array of a JWKS document. A document that is itself a bare JWK (some providers hand one out) is accepted as a one-key set.

@ jwks_parse s text → ( Vec JwkKey )

@ jwk_kty_for_alg s alg → s

The key type an algorithm needs, or "" for an unknown algorithm.

@ jwk_crv_for_alg s alg → s

The EC curve an ES algorithm is defined over ("" when not ES).

@ jwk_matches JwkKey jk s kid s alg → b

Does this key answer to (kid, alg)? kid may be empty (the token header carried none) — then only the algebraic constraints apply.

@ jwks_select ( Vec JwkKey ) ks s kid s alg → i

Index of the first key matching (kid, alg), or -1.

@ jwks_select_from ( Vec JwkKey ) ks s kid s alg i from → i

The same, starting at from — so a verifier can walk EVERY candidate key rather than betting on the first. That is the case during a key rotation, when the provider publishes the old and the new key and the tokens in flight carry no kid to tell them apart.

@ jwks_has_kid ( Vec JwkKey ) ks s kid → b

Does the set hold a key with this kid at all? Used to decide whether an unknown kid justifies re-fetching the JWKS.

@ jwk_ec_point JwkKey jk → ( Vec u )

SEC1 uncompressed point 0x04‖X‖Y, left-padded to the curve width. Empty when the key is not an EC key of a curve we know.

@ jwk_thumbprint JwkKey jk → String


flow.nu

oauth/flow.nu — the authorization-code flow, end to end.

Three requests and one redirect:

  1. ( oauth_authorize_url p cfg state nonce pk )

→ send the user's browser here. The URL carries the PKCE challenge, the state and the nonce; the secrets behind them stay on this side.

  1. the provider redirects back to the client's redirect_uri with

?code=…&state=… — ( oauth_callback_code query state ) checks the state and hands back the code (or names the provider's own error, which arrives the same way).

  1. ( oauth_exchange_code p cfg code verifier )

→ POST to the token endpoint: the code plus the PKCE verifier, answered with the access token and — this is the point — the ID TOKEN, a signed JWT stating who just logged in.

Then ( oidc_verify_id_token p pol ( token_set_id_token ts ) ) turns that into an OidcIdentity. oauth_userinfo fetches the same profile from the UserInfo endpoint when the ID token is deliberately thin.

Also here: oauth_refresh (a new access token without the user) and oauth_client_credentials (a machine identity — no user at all).

Client authentication is client_secret_post by default and client_secret_basic on request (RFC 6749 §2.3.1); a public client simply sets no secret and relies on PKCE, which is the OAuth 2.1 shape for anything running on the user's machine.

API

: OauthConfig

: OauthConfig {
    String client_id
    String client_secret  // empty = a public client (PKCE only)
    String redirect_uri
    String scope  // space-separated; "openid" is what makes it OIDC
    String audience  // a resource indicator, when the provider wants one
    String prompt  // "none" · "login" · "consent" — empty to omit
    b basic_auth  // client_secret_basic instead of client_secret_post
}

@ oauth_config_new s client_id s redirect_uri s scope → *OauthConfig

@ oauth_config_free * OauthConfig c → v

@ oauth_config_set_secret * OauthConfig c s secret → v

@ oauth_config_set_audience * OauthConfig c s audience → v

@ oauth_config_set_prompt * OauthConfig c s prompt → v

@ oauth_config_set_basic_auth * OauthConfig c b on → v

@ oauth_authorize_url * OidcProvider p * OauthConfig cfg s state s nonce Pkce pk → String

The URL to send the user's browser to. state and nonce are the values the caller minted (pkce.nu) and must remember: state is compared on the way back, nonce is compared inside the ID token.

: CallbackParams

: CallbackParams {
    String code
    String state
    String error
    String error_description
}

@ callback_params_free CallbackParams cb → v

@ oauth_callback_parse s query → CallbackParams

Parse the query string of the redirect the provider sent the browser.

@ oauth_callback_code s query s expected_state → !String OauthErr

The authorization code, once the callback is proven to be the answer to the request WE started. A state that does not match is not a recoverable condition — it is someone else's login being fed to us.

: TokenSet

: TokenSet {
    String access_token
    String id_token  // the signed statement of WHO — empty for a
    // non-OIDC grant (client_credentials, plain OAuth)
    String refresh_token
    String token_type  // "Bearer"
    String scope  // what the provider actually granted
    i expires_in  // seconds, −1 when the provider said nothing
    i obtained_at  // our clock when the response arrived
}

@ token_set_free TokenSet t → v

@ token_set_access_token TokenSet t → s

@ token_set_id_token TokenSet t → s

@ token_set_refresh_token TokenSet t → s

@ token_set_token_type TokenSet t → s

@ token_set_scope TokenSet t → s

@ token_set_expires_at TokenSet t → i

When the access token stops being usable; −1 when unknown.

@ token_set_stale TokenSet t i now → b

Refresh a minute early — a token that expires in flight is a 401 the caller has to retry, and the clocks are not the same clock.

@ oauth_exchange_code * OidcProvider p * OauthConfig cfg s code s verifier → !TokenSet OauthErr

Exchange the authorization code for tokens. verifier is the PKCE secret whose challenge went out with the authorization request.

@ oauth_refresh * OidcProvider p * OauthConfig cfg s refresh_token → !TokenSet OauthErr

A new access token from a refresh token — no user interaction.

@ oauth_client_credentials * OidcProvider p * OauthConfig cfg → !TokenSet OauthErr

The machine grant: this service authenticating as itself, with no user behind it (RFC 6749 §4.4).

@ oauth_userinfo * OidcProvider p s access_token → !Json OauthErr

The profile as the provider will state it right now, fetched with the access token (OIDC core §5.3). Returns the owned claims object.

@ oauth_userinfo_identity * OidcProvider p s access_token s expect_sub → !OidcIdentity OauthErr

The identity behind an access token, taken from UserInfo rather than from a signed token — for a provider whose access tokens are opaque. The sub MUST match the ID token's when both are in play (OIDC core §5.3.2); with no ID token in hand, pass "" to skip that.


oauth.nu

oauth — OAuth 2.0 and OpenID Connect for NURL: identify the user, read the claims.

One include gives a program both halves of an OpenID Connect relying party, with no C dependency anywhere in the chain — the TLS, the JSON, the RSA/ECDSA/Ed25519 signature checks and the SHA-2 under them are all pure NURL:

$ deps/oauth/src/oauth.nu

── Log a user in ──────────────────────────────────────────────────

?? ( oidc_provider_discover https://accounts.example.com ) { T p → { : OauthConfig cfg ( oauth_config_new client_id redirect scope ) : Pkce pk ( pkce_new ) : String state ( oauth_state_new ) : String nonce ( oauth_nonce_new ) : String url ( oauth_authorize_url p cfg ( string_data state ) ( string_data nonce ) pk ) // … send the browser to url, receive the redirect … ?? ( oauth_callback_code query ( string_data state ) ) { T code → { ?? ( oauth_exchange_code p cfg ( string_data code ) ( string_data . pk verifier ) ) { T ts → { : OidcPolicy pol ( oidc_policy_new issuer client_id ) ( oidc_policy_set_nonce pol ( string_data nonce ) ) ?? ( oidc_verify_id_token p pol ( token_set_id_token ts ) ) { T id → { / . id subject — the user / } F e → { / ( oauth_err_name e ) / } } } F e → {} } } F e → {} } } F e → {} }

── Guard an API with the tokens it hands out ──────────────────────

( http_app_get a /me ( with_oidc_bearer p pol \ HttpRequest req OidcIdentity id → HttpResponse { ^ ( response_json 200 ( string_data ( oidc_identity_key id ) ) ) } ) )

── What is in here ────────────────────────────────────────────────

errors.nu OauthErr — one error type for the whole package jwk.nu JWK / JWKS: parse a key set, pick the key a token names jws.nu verify a JWT against a JWK — RS256/384/512, PS256, ES256/384, EdDSA, HS256; none and crit refused claims.nu OidcPolicy, claims_check (iss/aud/azp/exp/nbf/iat/ nonce/sub/max_age), the claim accessors, OidcIdentity pkce.nu PKCE verifier + S256 challenge, state, nonce provider.nu discovery, the JWKS cache with rotation-driven re-fetch, and oidc_verify_id_token flow.nu authorize URL, callback, code exchange, refresh, client_credentials, UserInfo guard.nu with_oidc_bearer / with_oidc_scope for the HTTP server

── What it will not do ────────────────────────────────────────────

There is no "just decode the token" convenience that skips verification, no alg: none, no HS256 against a published (public) key set unless the caller explicitly says the key is a shared secret, and no PKCE plain. Each of those is a real attack that a helpful default has shipped before; none of them is a feature.


errors.nu

oauth/errors.nu — one error type for the whole package.

Discovery, JWKS fetching, token-endpoint calls, signature checking and claim validation all fold into OauthErr, so a caller writes one ?? … { T id → … F e → ( oauth_err_name e ) } and is done. The human-readable detail that does not fit an enum — the OAuth server's own error_description, the claim that failed — is carried on the provider (oidc_provider_last_error), set by whichever call failed.

API

: | OauthErr

: | OauthErr {
    OaNetwork  // could not connect / the transport failed
    OaHttpStatus  // an endpoint answered non-2xx (detail in last_error)
    OaBadResponse  // the body was not the JSON this endpoint must return
    OaIssuerMismatch  // discovery doc's `issuer` ≠ the issuer we asked for
    OaNoJwks  // the provider publishes no usable key set
    OaNoKey  // no JWK matched the token's kid / alg
    OaBadToken  // not a well-formed JWS / claims are not an object
    OaBadSignature  // the signature did not verify under the chosen key
    OaAlgNotAllowed  // header `alg` is unsupported or outside the policy
    OaClaims  // a claim check failed (detail in last_error)
    OaServer  // the endpoint returned an OAuth error response
    OaState  // the redirect's `state` is not the one we sent
    OaConfig  // caller misconfiguration — a required endpoint/field is unset
}

@ oauth_err_name OauthErr e → s

@ oauth_err_bearer_code OauthErr e → s

The RFC 6750 §3.1 error= token a resource server puts in its WWW-Authenticate challenge for this failure.


jws.nu

oauth/jws.nu — verify a compact JWS (a JWT) against a JWK.

The stdlib's ext/jwt.nu signs and verifies with a key you already hold, for the three algorithms it chose to support. An OpenID Connect relying party has the opposite problem: the token arrives first and names its own key (kid) and algorithm (alg) in the header, and the key comes from the provider's JWKS. This module is that direction — header-driven verification over the whole algorithm set providers actually publish:

RS256 · RS384 · RS512 RSASSA-PKCS1-v1_5 (std/rsa.nu) PS256 RSASSA-PSS ES256 · ES384 ECDSA over P-256 / P-384 (std/ecdsa_p256.nu) EdDSA Ed25519 (std/ed25519.nu) HS256 HMAC-SHA-256, for a shared-secret oct key

( jws_verify_with_key jk token ) → !Json OauthErr owned claims ( jws_header_json token ) → !Json OauthErr owned header ( jws_header_str token key ) → String "" when absent ( jws_payload_unverified token ) → !Json OauthErr

alg: none is refused — it is not an algorithm, it is the absence of one, and every historical JWT break starts there. So is an alg the key cannot possibly carry: the key type is checked against the family before the signature is touched (jwk_matches), which is what stops the classic "verify an RSA-signed token with the HMAC path" confusion.

The claims come back as an owned Json object; time and issuer/ audience checks are NOT done here (claims.nu owns that), so a caller can inspect an expired token deliberately.

API

@ jws_header_json s token → !Json OauthErr

@ jws_payload_unverified s token → !Json OauthErr

@ _jws_json_str Json j s key → String

A string member of an already-parsed JOSE header / claims object.

@ jws_header_str s token s key → String

A string member of the JOSE header — alg, kid, typ. Empty when the token is malformed or the member is absent. Reading the header is how a verifier learns which key to fetch; it is never trusted beyond that, because the signature has not been checked yet.

@ jws_alg_supported s alg → b

@ jws_alg_symmetric s alg → b

Is alg a symmetric (shared-secret) algorithm? A relying party that verifies with a PUBLISHED key set must refuse these unless it really did configure a shared secret — accepting HS* against a JWKS is the key-confusion attack.

@ jws_verify_with_key JwkKey jk s token → !Json OauthErr

Verify token under jk. The key must already have been chosen for this token's kid/alg (jwks_select) — this re-checks the type/curve agreement anyway, because a verifier that trusts its caller's key choice is one refactor away from key confusion.


claims.nu

oauth/claims.nu — read the claims, and decide whether to believe them.

A verified signature only says the token was issued by the holder of a key. Everything that makes it an ANSWER to "who is this, and is this token for me, right now" lives in the claims, and the checks are exactly the ones an attacker attacks when they are skipped:

iss the token came from the issuer we trust, not another one aud it was minted for THIS client, not for a different relying party that would happily hand it to us (the confused deputy) azp with several audiences, the authorized party is still us exp it has not expired · nbf it has begun · iat it is not future nonce it answers the authorization request WE started (replay) sub there is a subject at all — the user this token identifies

OidcPolicy is the heap-owned statement of what this relying party will accept; claims_check applies it and names the first failure.

: OidcPolicy pol ( oidc_policy_new issuer client_id ) ( oidc_policy_set_leeway pol 60 ) ?? ( claims_check claims pol ( now_seconds ) ) { T e → ( nurl_eprintln ( claim_err_desc e ) ) F _ → { / believe it */ } }

OidcIdentity is the answer itself: the subject and the profile claims lifted out for direct use, with the full claim set still attached for anything else the provider sent (groups, roles, tenant).

API

: | ClaimErr

: | ClaimErr {
    ClNotObject  // the payload was not a JSON object
    ClExpired  // now ≥ exp (+ leeway)
    ClNotYetValid  // now < nbf (− leeway)
    ClIssuedInFuture  // iat is ahead of our clock by more than the leeway
    ClNoExpiry  // policy demands exp; the token has none
    ClIssuer  // iss ≠ the issuer we trust
    ClAudience  // our client id is not in aud
    ClAuthorizedParty  // azp names a different client
    ClNonce  // nonce ≠ the one we sent
    ClNoSubject  // no sub — the token identifies nobody
    ClMaxAge  // the authentication is older than max_age
}

@ claim_err_desc ClaimErr e → s

@ claims_str Json c s key → String

A string claim as an owned String; empty when absent or not a string.

@ claims_int_or Json c s key i missing → i

A numeric claim (NumericDate and friends); missing when absent.

@ claims_int Json c s key → i

@ claims_bool Json c s key → b

@ claims_has Json c s key → b

@ claims_string_list Json c s key → ( Vec String )

A claim that is either a string or an array of strings — aud, and the shape most providers use for groups and roles. Always an owned vector of owned Strings (free with claims_strings_free).

@ claims_strings_free ( Vec String ) v → v

@ claims_has_audience Json c s aud → b

@ claims_scopes Json c → ( Vec String )

The scope claim (RFC 8693 §4.2: one space-delimited string).

@ claims_has_scope Json c s scope → b

: OidcPolicy

: OidcPolicy {
    String issuer  // expected `iss`; empty = do not check (never for OIDC)
    String audience  // our client id, expected in `aud`; empty = skip
    String nonce  // the nonce we sent with the authorization request
    String algs  // space-separated `alg` allowlist; empty = every supported
    i leeway  // clock-skew tolerance in seconds
    i max_age  // reject an authentication older than this; 0 = no limit
    b require_sub
    b require_exp
    b allow_symmetric  // permit HS* (only with a configured shared secret)
}

@ oidc_policy_new s issuer s audience → *OidcPolicy

@ oidc_policy_free * OidcPolicy p → v

@ oidc_policy_set_issuer * OidcPolicy p s issuer → v

@ oidc_policy_set_audience * OidcPolicy p s audience → v

@ oidc_policy_set_nonce * OidcPolicy p s nonce → v

@ oidc_policy_set_algs * OidcPolicy p s algs → v

@ oidc_policy_set_leeway * OidcPolicy p i secs → v

@ oidc_policy_set_max_age * OidcPolicy p i secs → v

@ oidc_policy_require_sub * OidcPolicy p b on → v

@ oidc_policy_require_exp * OidcPolicy p b on → v

@ oidc_policy_allow_symmetric * OidcPolicy p b on → v

@ oidc_policy_alg_allowed * OidcPolicy p s alg → b

Is alg inside the policy's allowlist? An empty allowlist means "any algorithm the verifier supports", which still excludes HS* unless the caller deliberately allowed symmetric keys.

@ claims_check Json c * OidcPolicy pol i now → ?ClaimErr

None = every check passed. now is epoch seconds — passed in, not read here, so a test (or a caller with its own time source) is deterministic.

@ claims_check_now Json c * OidcPolicy pol → ?ClaimErr

: OidcIdentity

: OidcIdentity {
    String subject  // `sub` — stable, unique WITHIN the issuer
    String issuer  // `iss` — the pair (issuer, subject) is the user
    String email
    String name
    String username  // `preferred_username`
    String picture
    b email_verified
    i issued_at
    i expires_at
    Json claims  // everything else the provider said, owned
}

@ oidc_identity_from_claims Json claims → OidcIdentity

Takes OWNERSHIP of claims; freed by oidc_identity_free.

@ oidc_identity_free OidcIdentity id → v

@ oidc_identity_claim OidcIdentity id s key → String

Any other claim, by name — the provider-specific half of the profile.

@ oidc_identity_claim_list OidcIdentity id s key → ( Vec String )

@ oidc_identity_has_scope OidcIdentity id s scope → b

@ oidc_identity_key OidcIdentity id → String

"sub@issuer" — the globally unique name of the user, which is the pair, never the subject alone.

@ oidc_identity_describe OidcIdentity id → String

A one-line human rendering, for logs and CLI output.