Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ templates/**/.claude/commands/speckit*.md
# Python bytecode
__pycache__/
*.py[cod]

# Python tooling
.venv/
.coverage
2 changes: 2 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ useDefault = true
description = "Global allowlist"
paths = [
'''(.*?)(example|sample|fixture|test)(.*?)\.(env|json|ya?ml)$''',
# identity's conformance vectors, vendored by scripts/pin-contract.sh: tokens signed by a test key.
'''^contract/vectors/''',
]
regexes = [
'''EXAMPLE_[A-Z0-9_]+''',
Expand Down
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ dependencies = [
]
```

## identity

`PrincipalReader.read` is the one entry point: it takes a request's headers by name and returns the
`Principal`, or raises `PrincipalRefused` with the contract's reason. It reads the Gate's signed
`X-Identity-Principal` header and never the plain `X-Auth-Request-*` ones; without it, it verifies a
JWT access token against the Issuer's keys or introspects an opaque token, keeping each answer for
30 seconds. `IdentityUnavailable` means identity could not be asked, which is not a refusal.

```python
from jorisjonkers_commons.identity import (
PrincipalReader,
principal_middleware,
require_recent_two_factor,
require_role,
)

reader = PrincipalReader("https://auth.jorisjonkers.dev", "wiki", client_secret=secret)
app = principal_middleware(app, reader) # 401 when refused, 503 when identity is down
admin = require_role("admin")(admin_app) # 403 without the role
payments = require_recent_two_factor(5)(pay_app) # 303 to the Gate's step-up, 403 for tokens
```

The middleware and the helpers are plain ASGI, so they wrap a Starlette or FastAPI app or any one
of its routes; Starlette's `request.state.principal` holds what the middleware read.

The Principal type is generated from the contract pinned in `contract/`. To move to a new contract
release, pin it and regenerate:

```bash
scripts/pin-contract.sh contract/principal/v1.1.0
PRINCIPAL_UPDATE=true task test
```

## Working on it

```bash
Expand Down
30 changes: 30 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,37 @@ tasks:
cmd: task --list
silent: true

sync:
desc: Install the locked dependencies
cmd: uv sync --frozen

lint:
desc: All linters
deps: [sync]
cmds:
- task: lint:python
- task: lint:actions

lint:python:
desc: ruff, ruff format and mypy --strict
cmds:
- uv run ruff check .
- uv run ruff format --check .
- uv run mypy

lint:actions:
desc: actionlint on every workflow
cmd: actionlint

test:
desc: pytest with a branch coverage gate (pyproject's fail_under)
deps: [sync]
cmd: uv run pytest --cov --cov-report=term-missing

build:
desc: Build the wheel and sdist consumers install
cmd: uv build --quiet

secrets:
desc: Scan the working tree for secrets
cmd: gitleaks dir --no-banner --redact .
Expand All @@ -23,4 +45,12 @@ tasks:
desc: Everything CI runs on a pull request
cmds:
- task: lint
- task: test
- task: build
- task: secrets

fix:
desc: Apply formatters and autofixes
cmds:
- uv run ruff format .
- uv run ruff check --fix .
1 change: 1 addition & 0 deletions contract/REF
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
contract/principal/v1.1.0
86 changes: 86 additions & 0 deletions contract/mapping.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"version": "1.1",
"signedHeader": {
"header": "X-Identity-Principal",
"typ": "identity-principal+jwt",
"alg": ["RS256"],
"lifetimeSeconds": 60,
"claims": {
"iss": "the Issuer",
"aud": "the Client the request is for",
"iat": "when the Gate signed it",
"exp": "iat + lifetimeSeconds"
}
},
"fields": {
"subject": { "claim": "sub", "header": "X-Auth-Request-User" },
"kind": { "claim": "kind", "header": "X-Auth-Request-Kind" },
"email": { "claim": "email", "header": "X-Auth-Request-Email" },
"name": { "claim": "name", "header": "X-Auth-Request-Name" },
"roles": { "claim": "roles", "header": "X-Auth-Request-Roles", "headerFormat": "comma-separated" },
"via": { "claim": "via", "header": "X-Auth-Request-Via" },
"actor": {
"claim": "act",
"claimFormat": "RFC 8693 act: {\"sub\": actor.id, \"name\": actor.name}",
"header": "X-Auth-Request-Actor",
"headerFormat": "actor.name"
},
"twoFactor": { "claim": "two_factor" },
"twoFactorAt": { "claim": "two_factor_at", "claimFormat": "NumericDate; the Principal states it as RFC 3339 UTC" },
"sid": { "claim": "sid" },
"ver": { "claim": "ver" }
},
"plainHeaders": {
"rule": "The Gate sets every plain header on every allowed answer, empty when the field has no value, so a caller's copy of a header can never stand in for one the Gate left out. A reader of the contract reads only the signed header.",
"headers": [
"X-Auth-Request-User",
"X-Auth-Request-Email",
"X-Auth-Request-Name",
"X-Auth-Request-Roles",
"X-Auth-Request-Kind",
"X-Auth-Request-Via",
"X-Auth-Request-Actor"
]
},
"stepUp": {
"path": "/.auth/step-up",
"query": {
"minutes": "how recent the two-factor proof must be, 1 to 60",
"rd": "the path on the app's own host to come back to"
},
"rule": "An app that needs a fresher proof than a Principal states redirects the browser to this path on its own host. The person comes back to rd once the session proved two-factor within minutes, and the first Gate check after it states the new twoFactorAt. A Principal that came by personal-token, tool or machine cannot step up: the app answers 403 without a redirect."
},
"accessToken": {
"typ": "JWT",
"alg": ["RS256"],
"rule": "Since contract 1.1, a JWT access token for a User or a Machine Identity carries the Principal's claims beside its registered ones (iss, sub, aud, exp, client_id). zitadel/oidc signs access tokens and ID tokens alike as typ JWT, so a reader tells an access token by its client_id claim, which an ID token does not carry.",
"refusals": {
"wrong-typ": "The JWT's typ is not JWT, such as the Gate's signed Principal.",
"not-an-access-token": "The JWT names no client_id, as an ID token does not.",
"wrong-issuer": "iss is not the Issuer.",
"wrong-audience": "aud is not exactly the reader's own Client.",
"expired": "The reader's clock is at or past exp.",
"unknown-key": "No key in the Issuer's JWKS has the JWT's kid.",
"bad-signature": "The signature does not verify with the key the kid names.",
"unknown-major": "ver names a major version the reader does not know, or is absent."
}
},
"introspection": {
"rule": "Since contract 1.1, identity's answer at /oidc/introspect (RFC 7662) for a Personal Token or a Connected App's token carries the Principal's claims. A reader authenticates as its own Client and may keep an answer for at most 30 seconds.",
"refusals": {
"inactive": "The answer is active: false.",
"wrong-audience": "client_id or aud is not the reader's own Client.",
"unknown-major": "ver names a major version the reader does not know, or is absent."
}
},
"refusals": {
"missing": "No signed header, whatever plain headers the request carries.",
"wrong-typ": "The JWT's typ is not identity-principal+jwt.",
"wrong-issuer": "iss is not the Issuer.",
"wrong-audience": "aud is not the reader's own Client.",
"expired": "The reader's clock is at or past exp.",
"unknown-key": "No key in the Issuer's JWKS has the JWT's kid.",
"bad-signature": "The signature does not verify with the key the kid names.",
"unknown-major": "ver names a major version the reader does not know."
}
}
91 changes: 91 additions & 0 deletions contract/principal.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/JorisJonkers-dev/identity/blob/main/contract/principal/principal.schema.json",
"title": "Principal",
"description": "Who is calling an application, as identity states it on every channel. Contract major version 1; a minor version only adds optional fields, which a reader ignores when it does not know them.",
"type": "object",
"required": ["subject", "kind", "roles", "via", "twoFactor", "ver"],
"properties": {
"subject": {
"description": "The User's or Machine Identity's id.",
"type": "string",
"minLength": 1
},
"kind": {
"description": "Whether a person or a workload is calling.",
"enum": ["user", "machine"]
},
"email": {
"description": "The User's sign-in email.",
"type": "string",
"format": "email"
},
"name": {
"description": "The User's name.",
"type": "string"
},
"roles": {
"description": "The App Roles the caller holds on the Client the request is for, and no other Client's.",
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"via": {
"description": "How the caller signed in: a Single sign-on session, a Personal Token, a tool acting for the User, or a Machine Identity's own credential.",
"enum": ["session", "personal-token", "tool", "machine"]
},
"actor": {
"description": "For via tool, the tool acting for the User: its registration's client id and the name it registered with.",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string" }
}
},
"twoFactor": {
"description": "Whether the session proved two-factor.",
"type": "boolean"
},
"twoFactorAt": {
"description": "When the session last proved two-factor.",
"type": "string",
"format": "date-time"
},
"sid": {
"description": "The Single sign-on session, for via session.",
"type": "string",
"minLength": 1
},
"ver": {
"description": "The contract version the Principal was stated in, as major.minor.",
"type": "string",
"pattern": "^1\\.(0|[1-9][0-9]*)$"
}
},
"allOf": [
{
"if": { "properties": { "kind": { "const": "user" } } },
"then": { "required": ["email", "name"], "properties": { "via": { "not": { "const": "machine" } } } }
},
{
"if": { "properties": { "kind": { "const": "machine" } } },
"then": { "properties": { "via": { "const": "machine" } } }
},
{
"if": { "properties": { "via": { "const": "tool" } } },
"then": { "required": ["actor"] },
"else": { "not": { "required": ["actor"] } }
},
{
"if": { "properties": { "via": { "const": "session" } } },
"then": { "required": ["sid"] },
"else": { "not": { "required": ["sid"] } }
},
{
"if": { "properties": { "twoFactor": { "const": true } } },
"then": { "required": ["twoFactorAt"] },
"else": { "not": { "required": ["twoFactorAt"] } }
}
]
}
8 changes: 8 additions & 0 deletions contract/vectors/access-token/bad-signature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"description": "A token whose claims were changed after signing is refused.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJpYXQiOjE3OTI0OTc2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnRlc3QiLCJqdGkiOiJhdC0xIiwia2luZCI6InVzZXIiLCJuYW1lIjoiQWRhIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJhZG1pbiJdLCJzaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYzEiLCJzdWIiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYTEiLCJ0d29fZmFjdG9yIjp0cnVlLCJ0d29fZmFjdG9yX2F0IjoxNzkyNDk2NzAwLCJ2ZXIiOiIxLjEiLCJ2aWEiOiJzZXNzaW9uIn0.xEqJRTB3ynRjYFaabbr0aZNLKQdRVX6cJ5j3Onhbm4PnvQvwFtoi2YPNWK482B45vqgEcYtGBmhmU2Ve2qu28zHEWAq-94wVDvtnP29-ietc439sTaUv0-SXzsXKYryoZyW32-JtoIP1bLpi5iRQJbm4PuG0QhaTOE7YN4AL-Fd-SKbia7UChRHMMxR7aQ1QKH6zeQDKzJSDYgObob0ah8Dn7FftUBLEDK5xO-aUwDIqyO0SWCm2GaTM9ceZlLlRD_i8GT6OHt63J-0K-4Cu6xWRf9wYHPRzt8Tt6npchsntfx---rsUf_IhULtxO-ibvNfW-4yc1ULVcuWtOPZjqg",
"refusal": "bad-signature"
}
8 changes: 8 additions & 0 deletions contract/vectors/access-token/expired.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"description": "A token read at the moment it expires is refused.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:15:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJpYXQiOjE3OTI0OTc2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnRlc3QiLCJqdGkiOiJhdC0xIiwia2luZCI6InVzZXIiLCJuYW1lIjoiQWRhIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJ1c2VyIl0sInNpZCI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBjMSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBhMSIsInR3b19mYWN0b3IiOnRydWUsInR3b19mYWN0b3JfYXQiOjE3OTI0OTY3MDAsInZlciI6IjEuMSIsInZpYSI6InNlc3Npb24ifQ.xEqJRTB3ynRjYFaabbr0aZNLKQdRVX6cJ5j3Onhbm4PnvQvwFtoi2YPNWK482B45vqgEcYtGBmhmU2Ve2qu28zHEWAq-94wVDvtnP29-ietc439sTaUv0-SXzsXKYryoZyW32-JtoIP1bLpi5iRQJbm4PuG0QhaTOE7YN4AL-Fd-SKbia7UChRHMMxR7aQ1QKH6zeQDKzJSDYgObob0ah8Dn7FftUBLEDK5xO-aUwDIqyO0SWCm2GaTM9ceZlLlRD_i8GT6OHt63J-0K-4Cu6xWRf9wYHPRzt8Tt6npchsntfx---rsUf_IhULtxO-ibvNfW-4yc1ULVcuWtOPZjqg",
"refusal": "expired"
}
17 changes: 17 additions & 0 deletions contract/vectors/access-token/machine.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"description": "A Machine Identity's access token: kind machine, and no email or name.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImV4cCI6MTc5MjQ5ODUwMCwiaWF0IjoxNzkyNDk3NjAwLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0IiwianRpIjoiYXQtMSIsImtpbmQiOiJtYWNoaW5lIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJiYWNrdXAiXSwic3ViIjoiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMGI3IiwidHdvX2ZhY3RvciI6ZmFsc2UsInZlciI6IjEuMSIsInZpYSI6Im1hY2hpbmUifQ.PBxoWeNVSQ-Qn9haqOQPuSzDxF3iGBmEStlkhsYbu3Ej2IH5SEQqfmn0IJMDdEE75HmYjHdLQDmjO-UUGMagLzNzoGxSAWK-eM4UKehr99EwjAylIeo4sdzejTtK1PQh7HraPtc9auE55_N8ordk4uX_V4AUxTKU2DQNfUPq1X6JUjhX_iTC_FGkaTrfQ63Khg3pMpVK3elFy7pDDLBKopH6Crb6wb21CFLdyyuaMFCm3on7EnF-MhtV5fgYDyOZYOcmoNC_ilsVSeid3TTm83uTlOHCyogVsvjSh1gDga5oKg5KlH_z--mXEfh1qWribS8AhwaP3iqjK8mvv0tH3w",
"principal": {
"subject": "00000000-0000-4000-8000-0000000000b7",
"kind": "machine",
"roles": [
"backup"
],
"via": "machine",
"twoFactor": false,
"ver": "1.1"
}
}
21 changes: 21 additions & 0 deletions contract/vectors/access-token/minor-version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"description": "A later minor version's token is read, and the claim the reader does not know is ignored.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJmdXR1cmUiOiJhIGxhdGVyIGNsYWltIiwiaWF0IjoxNzkyNDk3NjAwLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0IiwianRpIjoiYXQtMSIsImtpbmQiOiJ1c2VyIiwibmFtZSI6IkFkYSIsIm5iZiI6MTc5MjQ5NzYwMCwicm9sZXMiOlsidXNlciJdLCJzaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYzEiLCJzdWIiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYTEiLCJ0d29fZmFjdG9yIjp0cnVlLCJ0d29fZmFjdG9yX2F0IjoxNzkyNDk2NzAwLCJ2ZXIiOiIxLjIiLCJ2aWEiOiJzZXNzaW9uIn0.Y2F3xek1fslaMYVvujZD3rtyXSWaVApP3GXjCJytk9HuMRU49Ij8DdULqFUdAsjiqJL7dUkmSkqx7S6vqC9-ku4gR4sN-GQEanFL2fe_0RV5BZ9dkvm5vEPaELEmFGk8WvjXQGEtVfs8X6_YIRU4vEpKvoFx_WZX6thYF1fT0NUiShZ_fpdOUk8xywL9gut-REV-0etM16xHnpNDyOE8RJRNXc88OLN9pdmwS8egYN4Jn1FDvUYJa2VMVXHycUEPvXKZewq-MB5CLdUvMKGAP7ZieYUbw5wl3vwpQH69g3tlAiaE28IZO9rC3exkxkdIQ9nxjCcmwxwFhIZ2uBAzjw",
"principal": {
"subject": "00000000-0000-4000-8000-0000000000a1",
"kind": "user",
"email": "ada@example.org",
"name": "Ada",
"roles": [
"user"
],
"via": "session",
"twoFactor": true,
"twoFactorAt": "2026-10-20T11:45:00Z",
"sid": "00000000-0000-4000-8000-0000000000c1",
"ver": "1.2"
}
}
8 changes: 8 additions & 0 deletions contract/vectors/access-token/not-an-access-token.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"description": "An ID token, which names no client_id, is no access token.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJpYXQiOjE3OTI0OTc2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnRlc3QiLCJqdGkiOiJhdC0xIiwia2luZCI6InVzZXIiLCJuYW1lIjoiQWRhIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJ1c2VyIl0sInNpZCI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBjMSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBhMSIsInR3b19mYWN0b3IiOnRydWUsInR3b19mYWN0b3JfYXQiOjE3OTI0OTY3MDAsInZlciI6IjEuMSIsInZpYSI6InNlc3Npb24ifQ.Mf2JTjmg707BUvK4T17GKAUs-SeUpszVlBj0Y-zvXz2RG-6d9dWBBurnaQzs6f79mQjZFhCjhKFLd4AQX23xxZWlDiyZcKqNCPs67xRqXZ0njUuDVcM-thnBIdkN4BUk3bEHkHWn49nUqhm2bPnkN-dZx6tbfo9XKlNsH6yGKReLa3qgWZoOGgK_OX1hKcpzg8e67bccGNqwTP76jFIJ9B48SeuGZX8gH_Na0EFad3LJcHKJH1hTshC54kP47eiXjLdOUbwsLZu0bWLZ6RM4SIs8ZnYgMiXBLVKFgB7Y8XOIbjhcuLDzefHGbiPtWkFsG6KfoqiLosSqvrLwIClP4Q",
"refusal": "not-an-access-token"
}
21 changes: 21 additions & 0 deletions contract/vectors/access-token/session.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"description": "A User's access token from a sign-in whose session proved two-factor fifteen minutes before.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1pc3N1ZXIiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJpYXQiOjE3OTI0OTc2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnRlc3QiLCJqdGkiOiJhdC0xIiwia2luZCI6InVzZXIiLCJuYW1lIjoiQWRhIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJ1c2VyIl0sInNpZCI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBjMSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBhMSIsInR3b19mYWN0b3IiOnRydWUsInR3b19mYWN0b3JfYXQiOjE3OTI0OTY3MDAsInZlciI6IjEuMSIsInZpYSI6InNlc3Npb24ifQ.xEqJRTB3ynRjYFaabbr0aZNLKQdRVX6cJ5j3Onhbm4PnvQvwFtoi2YPNWK482B45vqgEcYtGBmhmU2Ve2qu28zHEWAq-94wVDvtnP29-ietc439sTaUv0-SXzsXKYryoZyW32-JtoIP1bLpi5iRQJbm4PuG0QhaTOE7YN4AL-Fd-SKbia7UChRHMMxR7aQ1QKH6zeQDKzJSDYgObob0ah8Dn7FftUBLEDK5xO-aUwDIqyO0SWCm2GaTM9ceZlLlRD_i8GT6OHt63J-0K-4Cu6xWRf9wYHPRzt8Tt6npchsntfx---rsUf_IhULtxO-ibvNfW-4yc1ULVcuWtOPZjqg",
"principal": {
"subject": "00000000-0000-4000-8000-0000000000a1",
"kind": "user",
"email": "ada@example.org",
"name": "Ada",
"roles": [
"user"
],
"via": "session",
"twoFactor": true,
"twoFactorAt": "2026-10-20T11:45:00Z",
"sid": "00000000-0000-4000-8000-0000000000c1",
"ver": "1.1"
}
}
8 changes: 8 additions & 0 deletions contract/vectors/access-token/unknown-key.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"description": "A token signed by a key the Issuer's JWKS does not publish is refused.",
"issuer": "https://auth.example.test",
"client": "web",
"now": "2026-10-20T12:01:00Z",
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZlY3Rvci1zdHJhbmdlciIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsid2ViIl0sImNsaWVudF9pZCI6IndlYiIsImVtYWlsIjoiYWRhQGV4YW1wbGUub3JnIiwiZXhwIjoxNzkyNDk4NTAwLCJpYXQiOjE3OTI0OTc2MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnRlc3QiLCJqdGkiOiJhdC0xIiwia2luZCI6InVzZXIiLCJuYW1lIjoiQWRhIiwibmJmIjoxNzkyNDk3NjAwLCJyb2xlcyI6WyJ1c2VyIl0sInNpZCI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBjMSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBhMSIsInR3b19mYWN0b3IiOnRydWUsInR3b19mYWN0b3JfYXQiOjE3OTI0OTY3MDAsInZlciI6IjEuMSIsInZpYSI6InNlc3Npb24ifQ.Tc49Nle3ReDQbx3fVtnqOYCs85ZZKIRyWvIDoVXvCIPDd0TKi12uknBSn9Ebo9gviKMnZV3LF8ujVWksx3XoHfyE77lfepGTgu532W6-lC21U43HBY30kDlLpsa9hT1_p-4B6Ivxaip2UxZzN1DGifbRxIPBH3Ed6ZWbziM4MnHk2iIBpkoj2boFTcfLcpqqm4tC0_omqX72P9Hgzianc7F8KtuWN-h1UJ9LcxYiQPJOl35CUx-AxqXaBKd5jDn_n4c0o5eUpanTsoeFUCN78o41eL-ToZWfhuK5Zwk2RT91IyMe7Fn3Pe4oHoxKUvZNOgr7fccT0G2ZyBc3WdnvvQ",
"refusal": "unknown-key"
}
Loading
Loading