# Signed tokens

`crypto.verify_jwt` checks a JSON Web Token (JWT) that another service
signed, such as the token a partner's sign-in service gives a person.
You give it the service's public keys, and it gives the token's claims
once the signature, the times, the issuer and the audience all hold. It
checks a token as RFC 8725 advises.

A clinic lets members sign in through a partner's sign-in service, which
signs each token with Ed25519 and publishes its public keys as a key set
(RFC 7517). The clinic reads the keys as `Jwk`s and checks each token
against them. The algorithm is the key's, never the token's, and the
token's times, issuer and audience must hold. `verify_jwt` reads the
clock, so it is called in an action:

```jadpo
type PartnerKeySet = Object {
    keys: List<Jwk>
}

type KeySetText = Text {
    max_length: 100000
}

type KeySetUnreadable = Failure {
    kind: FailureKind.invalid_value
}

type PartnerTokenRefused = Failure {
    kind: FailureKind.invalid_value
}

type PartnerTokenExpired = Failure {
    kind: FailureKind.invalid_value
}

/// The partner's public keys, from the key set it publishes (RFC 7517).
function partner_keys(published_key_set: KeySetText) fails KeySetUnreadable -> List<Jwk> {
    var partner_key_set: PartnerKeySet = attempt json.read(published_key_set, as: PartnerKeySet) {
        malformed: KeySetUnreadable
        mismatch: KeySetUnreadable
    }
    return partner_key_set.keys
}

/// What a token from the partner's sign-in service says, once its
/// signature, its times, its issuer and its audience hold.
action partner_sign_in(
    partner_token: Jwt,
    partner_signing_keys: List<Jwk>
) fails PartnerTokenRefused, PartnerTokenExpired -> JwtClaims {
    return attempt crypto.verify_jwt(
        partner_token,
        keys: partner_signing_keys,
        issuer: "https://id.example.com",
        audience: "clinic"
    ) {
        invalid_signature: PartnerTokenRefused
        expired: PartnerTokenExpired
        not_yet_valid: PartnerTokenRefused
        malformed: PartnerTokenRefused
        wrong_audience: PartnerTokenRefused
        wrong_issuer: PartnerTokenRefused
    }
}
```

The test's clock stands at 2026-10-18T09:00:00Z, and each token was made
with RFC 8032's first test key, whose public half the key set holds. The
refused tokens are the classic attacks: one past its expiry, one that
names `alg: none`, one signed with HMAC SHA-256 keyed with the public key
(mistaking a public key for a shared secret), one whose claims were
changed after signing, and one issued for another audience:

```jadpo
test "a partner's token signs in only while it holds" {
    clock: "2026-10-18T09:00:00Z"

    var partner_signing_keys: List<Jwk> = attempt partner_keys(
        "{\"keys\": [{\"kty\": \"OKP\", \"crv\": \"Ed25519\", \"x\": \"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\", \"kid\": \"2026-10\"}]}"
    )
    var genuine: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.nOqN7320O3T8e_AyF7XNt32jmgjE8VCU7GVHL89ZUeiRcU3gGM3EgiNxVp3_y422De1PZWd2tZ0LuSqdfm6QCg"
    var partner_claims: JwtClaims = attempt partner_sign_in(genuine, partner_signing_keys)
    assert partner_claims.sub == "member-42"
    assert partner_claims.aud == ["clinic"]
    var past_expiry: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzEzOTY5LCJqdGkiOiJhMWIyYzMifQ.0XDMfIlEkopB8bJAJCe95i2zQMGu9mbv_kUtc1QC0xMm5wxmsS9munCCG36_4NkaNcfDKNgaGpswq5QNuh_KDw"
    assert partner_sign_in(past_expiry, partner_signing_keys) fails PartnerTokenExpired
    var alg_none_token: Jwt = "eyJhbGciOiJub25lIiwia2lkIjoiMjAyNi0xMCJ9.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.bUBJ-HYfcv-x-XVebqotpXUryxrUsu15W2sejqXFebmY2OB3-XXAQIWG71XLjOBQDrJnDyfN4RTsPW20OqqBDA"
    assert partner_sign_in(alg_none_token, partner_signing_keys) fails PartnerTokenRefused
    var shared_secret_forgery: Jwt = "eyJhbGciOiJIUzI1NiIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.LEqGD4Thr8CB21DsacQjPDe-b_CRUkja0n3gjc6sT7M"
    assert partner_sign_in(shared_secret_forgery, partner_signing_keys) fails PartnerTokenRefused
    var altered: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTEiLCJhdWQiOiJjbGluaWMiLCJleHAiOjE3OTIzMTc2MDAsIm5iZiI6MTc5MjMxMzk0MCwiaWF0IjoxNzkyMzEzOTQwLCJqdGkiOiJhMWIyYzMifQ.nOqN7320O3T8e_AyF7XNt32jmgjE8VCU7GVHL89ZUeiRcU3gGM3EgiNxVp3_y422De1PZWd2tZ0LuSqdfm6QCg"
    assert partner_sign_in(altered, partner_signing_keys) fails PartnerTokenRefused
    var for_the_pharmacy: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoicGhhcm1hY3kiLCJleHAiOjE3OTIzMTc2MDAsIm5iZiI6MTc5MjMxMzk0MCwiaWF0IjoxNzkyMzEzOTQwLCJqdGkiOiJhMWIyYzMifQ.ysn61bX4ZDaJ7OCiuPNtYwJ1POMD7b1tVLIfZsDS1oimtwY3XgyhZDpLSkDph6j3fol8nv4KPZQ0g7Ai9pd4Bw"
    assert partner_sign_in(for_the_pharmacy, partner_signing_keys) fails PartnerTokenRefused
}
```

## Three kinds of key, one algorithm each

A `Jwk`'s `kty` is one of:

- `OKP`, an Ed25519 key (`crv` and `x`), used with `EdDSA`;
- `RSA` (`n` and `e`), used with `RS256`;
- `EC`, a P-256 key (`crv`, `x` and `y`), used with `ES256`.

A token is tried only against keys used with the algorithm its header
names, and each with that key's own algorithm. So `none`, `HS256`, an
RS256 token naming an EC key's id, and the reverse, all fail
`invalid_signature` before any key is tried. A key whose own `alg` names
an algorithm other than the token's is never tried. A key set naming any other algorithm,
such as RS384, RS512, PS256 or ES384, doesn't read. HS256 needs a shared
secret held as data, which waits for keys held as data
([Language gaps](language-gaps-this-page-depends-on.md)).

## Keys come only from the call

A header's `jwk`, `jku`, `x5u` and `x5c` are never read. A header's
`kid` narrows the keys tried to those with that id; with none, every key
is tried. A header with `crit` fails `invalid_signature` (RFC 7515
§4.1.11), and a nested token (`cty: JWT`) is `malformed`.

## The signature first

Nothing the claims say is read until a key has verified the signature,
strictly. Then come the times, then the issuer, then the audience.

- Ed25519 is checked as `verify_signature` checks it.
- RS256 is RSASSA-PKCS1-v1_5 with SHA-256. It is checked by encoding the
  whole padded digest and comparing it (RFC 8017 §8.2.2), never by
  parsing it, and the signature must be exactly as long as the modulus.
- An ES256 signature is R and S, 64 octets (RFC 7518 §3.4), each from 1
  to below the group's order. A high S is accepted, as RFC 7518, `p256`
  and RFC 7515 §A.3's own example allow. That matters only to something
  keyed on a token's text, and Jadpo keeps no token.

## Times

The times have 30 seconds' leeway: `exp` must be later than now, and
`nbf` and `iat` no later. A token that breaks one fails `expired` or
`not_yet_valid`. A token with no `exp` is `malformed`, so no token lives
for ever. A numeric date is a whole number of seconds within an
`Instant`'s range; a fraction is `malformed`.

`verify_jwt` reads the clock (`Op::reads_clock`), as `clock.now` does,
so a function calling it is `type.clock_context`. In a test, it reads the
test's fixed clock.

## Issuer and audience

`issuer:` and `audience:` are each optional. The issuer is checked only
when the call names one, so `wrong_issuer` needs its arm only then. The
audience is always checked: a token with an audience is refused unless
the call names one of them, and a token with none is refused when the
call names one (RFC 7519 §4.1.3). Both compare exactly, as RFC 7519
compares a StringOrURI.

## One reading of the JSON

The header and the claims must each be one JSON object with no member
named twice at any depth, or the token is `malformed` (RFC 7515 §5.2 and
RFC 7519 §7.2 allow refusing one). So no other reader can take a
different value from the same token.

## Refused tokens

Refusals are `invalid_value` failures, as every standard operation's
conditions map to. Nothing here makes a refused token `unauthenticated`.

## Your own claims: `as:`

`as:` reads a token's claims into an `Object` your application declares.
Its fields are read as `json.read` reads an object's. A claim missing, of
the wrong kind or breaking a rule is `mismatch`, and its first problem
fills a `problem: DecodeProblem` field as `json.read`'s does. Claims the
type doesn't name are ignored, as RFC 7519 §4 requires of claims not
understood, though a nested object it names is read strictly.

Anything but your application's `Object` (`Text`, `JwtClaims`, a list)
is `type.mismatch`. Nothing is read as the type until the signature, the
times, the issuer and the audience hold, and `mismatch` needs its arm
only when `as:` is given.

A field the type has for one of the seven registered claims (`iss`,
`sub`, `aud`, `exp`, `nbf`, `iat`, `jti`) is filled with the value the
call checked, exactly as `JwtClaims` holds it. So one call gives a
token's subject and its own claims. `iss`, `sub` and `jti` are text,
`aud` a list of text, and `exp`, `nbf` and `iat` an `Instant`. A field
declared as another type (`sub: Int`, `exp: Text`) is `type.mismatch`.
One built on its type (`sub: MemberId`) keeps its own rules, and one
without `?` that the token leaves out is `mismatch` (`required`).

## The token types

These types hold what `verify_jwt` takes and gives.

**`Jwt`** is a JWS in compact form: three parts of unpadded Base64url in
one spelling, none empty, so an unsecured token (RFC 7519 §6) is never
one, and at most 8,192 characters. A token is a credential, so it is a
secret value ([Secret values](secret-values.md)), never shown.

**`Jwk`** holds one public key and never a secret. Its parts are `kty`,
`crv`, `x`, `y`, `n`, `e`, `kid` (1 to 256 printable ASCII characters),
`alg`, `use_` (`sig`, sent as RFC 7517's `use`) and `x5c`, each but
`kty` optional. Three rules across them hold a key to its kind:
`parts_of_its_kty` (its kind's parts and no other's),
`algorithm_of_its_kty` (an `alg`, where given, is its kind's) and
`a_usable_key`.

- Ed25519's `x` is 32 bytes as unpadded Base64url in one spelling.
- An RSA modulus is 2,048 to 4,096 bits, odd and in its fewest octets.
  Under 2,048 bits can be factored (RFC 7518 §3.3 requires 2,048), and
  4,096 is the most `rsa` checks by default, so a key set can't make
  checking a token cost without bound. The exponent is odd, from 3 to
  2^32 - 1, in its fewest octets.
- A P-256 point is checked to be on the curve, both by the type's rule
  and by `p256` again before it is used, each coordinate 32 octets in
  full (RFC 7518 §6.2.1.2).

A `Jwk` is shown by its key id, else its `x`, else its `n`.

Members a `Jwk` doesn't hold are ignored, as RFC 7517 §4 requires
(Microsoft's key set sends `issuer` and `cloud_instance_name`, and
Microsoft's and Auth0's send `x5t`). It is the one standard object that
ignores them. But a private key's or a shared secret's members (`d`, `p`,
`q`, `dp`, `dq`, `qi`, `oth`, `k`), and `key_ops`, which limits a key in
a way a `Jwk` doesn't hold, make JSON not a `Jwk`. So a key set read from
outside can't put a secret in a program. A key set whose keys don't all
read is `mismatch` as a whole, as any list is.

A `Jwk`'s `x5c` is carried, and held to the key. It is the certificates'
DER in standard Base64, as RFC 7517 §4.7 sends them, read with
`encoding.from_base64` and `crypto.read_certificate`. A key with
certificates is tried only when its first certificate reads strictly and
holds the same public key (RFC 7517 §4.7's MUST). Nothing checks the
chain, which `x5c` alone doesn't make trusted. `x5u`, `x5t` and a
header's `x5c` are never read.

**`JwtClaims`** holds RFC 7519's seven registered claims by their own
names: `aud` is a list (one audience is read as a list of one), `exp` is
always there, and each text claim is one line of 1 to 1,024 characters.
It is sent with those names and Jadpo's instants, not numeric dates, and
leaves out the claims it doesn't have. It is stored in a column per
claim, and shown by its subject, else its issuer, else its id, else its
expiry. A token's other claims (`scope`, `email`) are read with `as:`.

**`OAuthScope`** is one RFC 6749 scope-token of 1 to 256 characters.
The enum [Policy](policy.md) generates from the scopes
`authentication.jadpo` declares narrows this type to them, as a closed
subset, rather than reusing its name.

**`OidcClaims`** holds OpenID Connect Core's subject (1 to 255 printable
ASCII characters) and the claims a sign-in reads: `name` and
`preferred_username` as lines, `given_name` and `family_name` as the
standard types, `email`, `email_verified`, `phone_number`,
`phone_number_verified` and `picture`. `gender` stays out, by the rule
in [The standard types](the-standard-types.md). `birthdate`, `address`,
`locale`, `zoneinfo` and `updated_at` aren't held, since a sign-in
doesn't need them. JSON naming a claim it doesn't hold isn't an
`OidcClaims`, as for every object. It is shown by its name, else its
email, else its subject.

> `crypto.verify_jwt` and `crypto.read_certificate` are built, and read
> and check Google's, Microsoft's and Auth0's key sets and tokens. They
> are tested against RFC 7515's, RFC 7519's, RFC 7520's and RFC 8037's
> examples and the classic attacks under each algorithm. Neither reads
> randomness, and both build for WebAssembly. Both only verify, so
> RUSTSEC-2023-0071 (Marvin), which leaks an RSA private key through the
> timing of decryption and signing, doesn't touch them. RS384, RS512,
> PS256, ES384 and the other algorithms aren't built, and neither is
> `OAuthScope`.
