From 95445d4dd3a23318901e00332a29f83243094dc8 Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Fri, 28 Aug 2026 21:16:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20publish=20the=20claim-level=20provi?= =?UTF-8?q?der=E2=86=94verifier=20claims=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JWT contract between auth.provider and auth.policy-verifier was mapped only at signature mechanics (alg/key symmetry); the claim half — which claims cross, who writes and reads each, what each side means by them — existed only as comments scattered across both repos and two provider CHANGELOG asides. docs/claims-contract.md consolidates that correspondence as an index over the existing mapping statements: seven crossing claims (scope, sub, azp, aud, iss, typ, exp/iat), two deliberately non-crossing ones (jti, groups), the E2E tests that pin rows executable, and the two-repo change protocol. Rows are anchored to stable names (collector classes, config keys) with file paths as secondary hints. Linked from architecture.md's Auth Flow and the README, with .ja.md twins per docs convention. Closes #13 Co-Authored-By: Claude Fable 5 --- README.ja.md | 2 +- README.md | 2 +- docs/architecture.ja.md | 2 ++ docs/architecture.md | 2 ++ docs/claims-contract.ja.md | 43 ++++++++++++++++++++++++++++++++++++++ docs/claims-contract.md | 43 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 docs/claims-contract.ja.md create mode 100644 docs/claims-contract.md diff --git a/README.ja.md b/README.ja.md index ad568c3..6600ccb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -74,7 +74,7 @@ auth.policy-verifier (ABAC) gRPC サービスの場合、[protobuf.interceptors](https://github.com/o3co/protobuf.interceptors) がインターセプターを提供し、ポリシー検証器を呼び出す(OPA/Cedar を代替バックエンドとしても利用可能)。 -詳細なフローとコンポーネントの説明は [docs/architecture.ja.md](docs/architecture.ja.md) を参照。 +詳細なフローとコンポーネントの説明は [docs/architecture.ja.md](docs/architecture.ja.md) を、auth.provider と auth.policy-verifier の間の claim レベル JWT 契約は [docs/claims-contract.ja.md](docs/claims-contract.ja.md) を参照。 ## はじめに diff --git a/README.md b/README.md index ed2dbe1..fa6a4fd 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ auth.policy-verifier (ABAC) For gRPC services, [protobuf.interceptors](https://github.com/o3co/protobuf.interceptors) provides interceptors that call the policy verifier (or OPA/Cedar as alternative backends). -See [docs/architecture.md](docs/architecture.md) for detailed flow and component descriptions. +See [docs/architecture.md](docs/architecture.md) for detailed flow and component descriptions, and [docs/claims-contract.md](docs/claims-contract.md) for the claim-level JWT contract between auth.provider and auth.policy-verifier. ## Getting Started diff --git a/docs/architecture.ja.md b/docs/architecture.ja.md index 6e5dd13..939886f 100644 --- a/docs/architecture.ja.md +++ b/docs/architecture.ja.md @@ -43,6 +43,8 @@ Client 5. ダウンストリームサービスが `auth.policy-verifier`(`POST /verify`)を呼び出し、きめ細かな ABAC チェックを実行 6. gRPC サービスの場合、`protobuf.interceptors` インターセプターが `.proto` オプションからポリシーを解決し、検証器を呼び出す +手順 2 と 5 の間の claim レベル契約 — どの JWT claim が provider→verifier 境界を越え、誰が書き・誰が読み、それぞれの側で何を意味するか — は [claims-contract.ja.md](claims-contract.ja.md) に規定する。 + ## マイグレーションパス 各コンポーネントはスタンドアロンの HTTP サービスとして動作する。OPA や Cedar へのマイグレーション方法: diff --git a/docs/architecture.md b/docs/architecture.md index 58e73ac..2f3d09a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,8 @@ Client 5. Downstream service calls `auth.policy-verifier` (`POST /verify`) for fine-grained ABAC checks 6. For gRPC services, `protobuf.interceptors` interceptors resolve policy from `.proto` options and call the verifier +The claim-level contract between steps 2 and 5 — which JWT claims cross the provider→verifier boundary, who writes and reads each, and what each side means by them — is specified in [claims-contract.md](claims-contract.md). + ## Migration Path Each component runs as a standalone HTTP service. To migrate to OPA or Cedar: diff --git a/docs/claims-contract.ja.md b/docs/claims-contract.ja.md new file mode 100644 index 0000000..6f11195 --- /dev/null +++ b/docs/claims-contract.ja.md @@ -0,0 +1,43 @@ +# Provider ↔ Verifier Claims Contract(クレーム契約) + +[auth.provider](https://github.com/o3co/auth.provider) と [auth.policy-verifier](https://github.com/o3co/auth.policy-verifier) の間の JWT 契約には 2 つの半面がある。**署名の半面** — アルゴリズムと鍵配布の対称性(HS256/RS256/ES256/EdDSA、共有 secret または JWKS URI)— はデプロイ設定であり、各 repo の README に記載されている。本文書は **claim の半面** を記録する: どの claim が境界を越えるか、誰が書き、誰が読み、それぞれの側で何を意味するか。 + +各 repo の語彙はその repo の内側でのみ絶対である: provider は OAuth/OIDC 語彙(RFC 準拠)で claim を書き、verifier は自身のエッジでそれを自らの ABAC attribute 語彙へ翻訳する。下の表はその対応関係である。両 repo と本 repo の E2E スイートに散在する既存のマッピングコメントを集約した **索引** であり、第二の真実の源ではない。表と引用先コードが食い違う場合はコードとテストが勝ち、この表がドリフトしている。 + +## 境界オブジェクト + +RFC 9068 JWT access token。provider は `generateToken`(`packages/core/src/grants/token.mts`)で claim を組み立て、`packages/oauth/src/grants/` 配下の各 grant で刻印する。verifier は `tokenAuthenticator`(`packages/server/src/jwt/`)で token を認証し、builtins の collector が検証済み claim を ABAC attribute(`packages/core/src/keys.mts` の `ATTR_*` キー)へ翻訳する。 + +## 境界を越える claim + +| Claim | Provider が書く | Verifier が読む | 境界での意味 | +| --- | --- | --- | --- | +| `scope` | 空白区切り文字列(RFC 6749 §3.3)。何も付与されない場合は完全に省略 — `""` にも配列にもしない(`oauth/src/grants/authorization.mts` の CP-12)。上限: client 登録の `allowedScopes`。 | `PayloadScopeCollector` が空白で分割 → `ATTR_SCOPES`。続いて `ResourceActionScopeRuleCollector` が `/verify` リクエストから導出した `{action}:{resourceType}` scope を要求する。 | capability の上限 — セッションが **要求できるもの** であって付与されたものではない(collector の doc comment と [auth.provider#56](https://github.com/o3co/auth.provider/issues/56) 参照)。scope 値の *内部文法* は verifier が所有し、provider は文字列を不透明に比較するだけ。 | +| `sub` | セッションストア由来の user id — session id ではない(`oauth/src/grants/authorization.mts`)。 | `PayloadSubjectIdCollector` → `ATTR_USER_ID`。`/verify` は wire の `subject` として反響する。 | identity はスタックを通して不変に伝わる。 | +| `azp` | *認証済み* client id であり、body 生の `client_id` ではない(`oauth/src/grants/authorization.mts` の D-6)。 | `PayloadSubjectIdCollector` → `ATTR_CLIENT_ID`。 | token がどの client 経由で発行されたか。 | +| `aud` | RFC 8707 `resource` パラメータを audience として反響。refresh でパラメータが繰り返されなければ落ちる(§2.2)。 | jose 検証でピン — `oauth.jwt.audience` / `OAUTH_JWT_AUDIENCE`。 | token の宛先 resource server。E2E 値 `https://api.e2e.test` が `tests/provider/clients.yaml`(`allowedAudiences`)**と** verifier の env の両方に現れるのはこのため。 | +| `iss` | デプロイ設定の issuer。必須、リクエスト由来にしない。 | jose 検証でピン — `oauth.jwt.issuer` / `OAUTH_JWT_ISSUER`。 | デプロイの identity。 | +| `typ`(header) | access token は `at+jwt`(RFC 9068)、refresh token は `rt+jwt`、id_token は `id+jwt`。 | `oauth.jwt.tokenType`、default `at+jwt`。比較時に `application/` prefix は無視。 | 3 種の token を区別する **唯一の** 判別子 — このピンが、同じ鍵で署名された refresh / id token が `/verify` を通ることを防いでいる。claim レベルの代替チェックは存在しない。 | +| `exp` / `iat` | 常に刻印(`core/src/grants/token.mts`)。 | 両方必須(`tokenAuthenticator` の必須 claim チェック + 常設の `maxTokenAgeSeconds` 上限)。`exp` のない token は永続 credential として拒否。 | 寿命。verifier は issuer の規律を信頼せず、issuer の `exp` をさらに上限で抑える。 | + +## 境界を越えない claim + +結合が「想定で発生する」ことを防ぐために記録する: + +| Claim | 状態 | +| --- | --- | +| `jti` | 全 token に刻印(`core/src/grants/token.mts`)、provider 側の replay 検出に使用。verifier は一切読まない。 | +| `groups` | scope ゲート付き claim filter(`core/src/grants/claimFilter.mts`、`groups` scope)経由で userinfo / id_token にのみ到達 — access token には **入らない**。verifier の group 系 attribute は `/verify` リクエストの `context` から `RequestContextAttributeCollector` 経由で来る: 別チャネル・別信頼境界(呼び出し元供給の body であり、検証済み token ではない)。 | + +## 実行可能な行 + +いくつかの行は本 repo のテストでピンされている — 行を弱める前に読むこと: + +- `typ` 判別: `tests/token-flow/index.test.js` が 3 つの header 全てをピン。`tests/abac/index.test.js`("only access tokens are decision inputs")は id_token と refresh token を `/verify` に提示して拒否を要求する。「なにかの 4xx」に弱めないこと。 +- `scope` は文字列であり配列ではない: `tests/token-flow/index.test.js` — "a `scopes` array would silently authorize nothing"([o3co/auth#3](https://github.com/o3co/auth/issues/3) が指摘したドリフト)。 +- RFC 8707 `resource` パラメータ経由の `aud`: `tests/token-flow/index.test.js` + `tests/provider/clients.yaml`(`allowedAudiences`)。 +- `sub` の不変伝搬: `tests/abac/index.test.js`(provider の `sub` → `/verify` の `subject`)。 + +## 変更プロトコル + +すべての行は 2-repo 制約である: 片側だけ変えると `/verify` が壊れる — 最悪の場合、静かに何も認可しなくなる。行を変更する PR は (1) 所有側 repo、(2) 本 repo の E2E ピン、(3) この表、を更新する。署名の半面の同等ルール(alg/鍵の対称性)は `tests/` 配下の compose/Makefile 単一定義化で強制されている。 diff --git a/docs/claims-contract.md b/docs/claims-contract.md new file mode 100644 index 0000000..e90ecee --- /dev/null +++ b/docs/claims-contract.md @@ -0,0 +1,43 @@ +# Provider ↔ Verifier Claims Contract + +The JWT contract between [auth.provider](https://github.com/o3co/auth.provider) and [auth.policy-verifier](https://github.com/o3co/auth.policy-verifier) has two halves. The **signature half** — algorithm and key-distribution symmetry (HS256/RS256/ES256/EdDSA, shared secret or JWKS URI) — is deployment configuration, documented in each repo's README. This document records the **claim half**: which claims cross the boundary, who writes them, who reads them, and what each side means by them. + +Each repo's vocabulary is absolute only within that repo: the provider writes claims in OAuth/OIDC vocabulary (RFC-grounded), and the verifier translates them into its own ABAC attribute vocabulary at its edge. The table below is that correspondence. It consolidates mapping statements that already exist as comments scattered across both repos and the E2E suites here — an index over those statements, not a second source of truth. Where a row and the cited code disagree, the code and its tests win and this table has drifted. + +## The boundary object + +The RFC 9068 JWT access token. The provider assembles claims in `generateToken` (`packages/core/src/grants/token.mts`) and stamps them per grant under `packages/oauth/src/grants/`. The verifier authenticates the token in `tokenAuthenticator` (`packages/server/src/jwt/`), then builtins collectors translate the verified claims into ABAC attributes (the `ATTR_*` keys in `packages/core/src/keys.mts`). + +## Claims that cross + +| Claim | Provider writes | Verifier reads | Meaning at the boundary | +| --- | --- | --- | --- | +| `scope` | Space-delimited string (RFC 6749 §3.3), or omitted entirely when nothing was granted — never `""`, never an array (CP-12 in `oauth/src/grants/authorization.mts`). Ceiling: the client's registered `allowedScopes`. | `PayloadScopeCollector` splits on spaces → `ATTR_SCOPES`; `ResourceActionScopeRuleCollector` then requires the `{action}:{resourceType}` scope it derives from each `/verify` request. | Capability ceiling — what the session **can request**, not what it has been granted (see the collector's doc comment and [auth.provider#56](https://github.com/o3co/auth.provider/issues/56)). The *grammar inside* a scope value is owned by the verifier; the provider only compares the strings opaquely. | +| `sub` | The user id from the session store — never the session id (`oauth/src/grants/authorization.mts`). | `PayloadSubjectIdCollector` → `ATTR_USER_ID`; `/verify` echoes it as the wire `subject`. | Identity travels intact across the stack. | +| `azp` | The *authenticated* client id, not the raw body `client_id` (D-6 in `oauth/src/grants/authorization.mts`). | `PayloadSubjectIdCollector` → `ATTR_CLIENT_ID`. | Which client the token was issued through. | +| `aud` | The RFC 8707 `resource` parameter echoed back as the audience; dropped on refresh when the parameter is not repeated (§2.2). | Pinned by jose verification — `oauth.jwt.audience` / `OAUTH_JWT_AUDIENCE`. | The resource server the token is addressed to. The E2E value `https://api.e2e.test` appears in `tests/provider/clients.yaml` (`allowedAudiences`) *and* the verifier's env for exactly this reason. | +| `iss` | Deployment-configured issuer; required, never request-derived. | Pinned by jose verification — `oauth.jwt.issuer` / `OAUTH_JWT_ISSUER`. | Deployment identity. | +| `typ` (header) | `at+jwt` on access tokens (RFC 9068), `rt+jwt` on refresh tokens, `id+jwt` on id_tokens. | `oauth.jwt.tokenType`, default `at+jwt`; an `application/` prefix on either side is ignored when comparing. | The **only** discriminator between the three token kinds — the pin is what keeps a refresh or id token signed with the same key from passing `/verify`. There is no claim-level fallback check. | +| `exp` / `iat` | Always stamped (`core/src/grants/token.mts`). | Both required (`tokenAuthenticator`'s required-claims check + the always-set `maxTokenAgeSeconds` bound); a token without `exp` is refused as a permanent credential. | Lifetime. The verifier additionally caps the issuer's `exp` rather than trusting issuer discipline. | + +## Claims that do NOT cross + +Recorded so the coupling cannot be assumed into existence: + +| Claim | Status | +| --- | --- | +| `jti` | Stamped on every token (`core/src/grants/token.mts`) and used provider-side for replay detection. The verifier never reads it. | +| `groups` | Reaches userinfo / id_token only through the scope-gated claim filter (`core/src/grants/claimFilter.mts`, `groups` scope) — it is **not** in the access token. The verifier's group-style attributes arrive via the `/verify` request `context` through `RequestContextAttributeCollector`: a separate channel with a separate trust boundary (caller-supplied body, not the verified token). | + +## Executable rows + +Several rows are pinned by tests in this repo — read them before weakening a row: + +- `typ` discrimination: `tests/token-flow/index.test.js` pins all three headers; `tests/abac/index.test.js` ("only access tokens are decision inputs") presents the id_token and refresh token at `/verify` and requires rejection. Do not weaken those into "some 4xx". +- `scope` is a string, not an array: `tests/token-flow/index.test.js` — "a `scopes` array would silently authorize nothing" (the drift [o3co/auth#3](https://github.com/o3co/auth/issues/3) called out). +- `aud` via the RFC 8707 `resource` parameter: `tests/token-flow/index.test.js` + `tests/provider/clients.yaml` (`allowedAudiences`). +- `sub` travels intact: `tests/abac/index.test.js` (provider `sub` → `/verify` `subject`). + +## Change protocol + +Every row is a two-repo constraint: changing either side alone breaks `/verify` — or worse, silently authorizes nothing. A PR that changes a row updates (1) the owning repo, (2) the E2E pin here, and (3) this table. The signature half's equivalent rule (alg/key symmetry) is enforced by the compose/Makefile single-sourcing under `tests/`. From 1258fb5aa1920b2ca6bc9ae3946a5b95598aeeca Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Fri, 28 Aug 2026 21:26:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20address=20Copilot=20review=20on=20#?= =?UTF-8?q?14=20=E2=80=94=20repo-qualify=20paths,=20ID-token=20spelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary-object paragraph cited packages/... paths without saying which sibling repo owns them — ambiguous in an umbrella doc where both siblings have a packages/core. Paths are now repo-qualified and the tables carry an explicit path convention. Also 'builtins collectors' → 'built-in collectors (its packages/builtins)', and 'id_tokens' → 'ID tokens (wire name id_token)'. Co-Authored-By: Claude Fable 5 --- docs/claims-contract.ja.md | 6 ++++-- docs/claims-contract.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/claims-contract.ja.md b/docs/claims-contract.ja.md index 6f11195..338cc8c 100644 --- a/docs/claims-contract.ja.md +++ b/docs/claims-contract.ja.md @@ -6,7 +6,9 @@ ## 境界オブジェクト -RFC 9068 JWT access token。provider は `generateToken`(`packages/core/src/grants/token.mts`)で claim を組み立て、`packages/oauth/src/grants/` 配下の各 grant で刻印する。verifier は `tokenAuthenticator`(`packages/server/src/jwt/`)で token を認証し、builtins の collector が検証済み claim を ABAC attribute(`packages/core/src/keys.mts` の `ATTR_*` キー)へ翻訳する。 +RFC 9068 JWT access token。provider は `generateToken`(auth.provider の `packages/core/src/grants/token.mts`)で claim を組み立て、auth.provider の `packages/oauth/src/grants/` 配下の各 grant で刻印する。verifier は `tokenAuthenticator`(auth.policy-verifier の `packages/server/src/jwt/`)で token を認証し、built-in collector(同 repo の `packages/builtins`)が検証済み claim を ABAC attribute(auth.policy-verifier の `packages/core/src/keys.mts` の `ATTR_*` キー)へ翻訳する。 + +以下の表の path 規約: *Provider が書く* 列の path は [o3co/auth.provider](https://github.com/o3co/auth.provider)、*Verifier が読む* 列は [o3co/auth.policy-verifier](https://github.com/o3co/auth.policy-verifier)、`tests/…` は本 repo からの相対 path。 ## 境界を越える claim @@ -17,7 +19,7 @@ RFC 9068 JWT access token。provider は `generateToken`(`packages/core/src/gr | `azp` | *認証済み* client id であり、body 生の `client_id` ではない(`oauth/src/grants/authorization.mts` の D-6)。 | `PayloadSubjectIdCollector` → `ATTR_CLIENT_ID`。 | token がどの client 経由で発行されたか。 | | `aud` | RFC 8707 `resource` パラメータを audience として反響。refresh でパラメータが繰り返されなければ落ちる(§2.2)。 | jose 検証でピン — `oauth.jwt.audience` / `OAUTH_JWT_AUDIENCE`。 | token の宛先 resource server。E2E 値 `https://api.e2e.test` が `tests/provider/clients.yaml`(`allowedAudiences`)**と** verifier の env の両方に現れるのはこのため。 | | `iss` | デプロイ設定の issuer。必須、リクエスト由来にしない。 | jose 検証でピン — `oauth.jwt.issuer` / `OAUTH_JWT_ISSUER`。 | デプロイの identity。 | -| `typ`(header) | access token は `at+jwt`(RFC 9068)、refresh token は `rt+jwt`、id_token は `id+jwt`。 | `oauth.jwt.tokenType`、default `at+jwt`。比較時に `application/` prefix は無視。 | 3 種の token を区別する **唯一の** 判別子 — このピンが、同じ鍵で署名された refresh / id token が `/verify` を通ることを防いでいる。claim レベルの代替チェックは存在しない。 | +| `typ`(header) | access token は `at+jwt`(RFC 9068)、refresh token は `rt+jwt`、ID token(wire 名 `id_token`)は `id+jwt`。 | `oauth.jwt.tokenType`、default `at+jwt`。比較時に `application/` prefix は無視。 | 3 種の token を区別する **唯一の** 判別子 — このピンが、同じ鍵で署名された refresh / id token が `/verify` を通ることを防いでいる。claim レベルの代替チェックは存在しない。 | | `exp` / `iat` | 常に刻印(`core/src/grants/token.mts`)。 | 両方必須(`tokenAuthenticator` の必須 claim チェック + 常設の `maxTokenAgeSeconds` 上限)。`exp` のない token は永続 credential として拒否。 | 寿命。verifier は issuer の規律を信頼せず、issuer の `exp` をさらに上限で抑える。 | ## 境界を越えない claim diff --git a/docs/claims-contract.md b/docs/claims-contract.md index e90ecee..bc09aa7 100644 --- a/docs/claims-contract.md +++ b/docs/claims-contract.md @@ -6,7 +6,9 @@ Each repo's vocabulary is absolute only within that repo: the provider writes cl ## The boundary object -The RFC 9068 JWT access token. The provider assembles claims in `generateToken` (`packages/core/src/grants/token.mts`) and stamps them per grant under `packages/oauth/src/grants/`. The verifier authenticates the token in `tokenAuthenticator` (`packages/server/src/jwt/`), then builtins collectors translate the verified claims into ABAC attributes (the `ATTR_*` keys in `packages/core/src/keys.mts`). +The RFC 9068 JWT access token. The provider assembles claims in `generateToken` (auth.provider's `packages/core/src/grants/token.mts`) and stamps them per grant under auth.provider's `packages/oauth/src/grants/`. The verifier authenticates the token in `tokenAuthenticator` (auth.policy-verifier's `packages/server/src/jwt/`), then the built-in collectors (its `packages/builtins`) translate the verified claims into ABAC attributes (the `ATTR_*` keys in auth.policy-verifier's `packages/core/src/keys.mts`). + +Path convention for the tables below: paths in the *Provider writes* column are relative to [o3co/auth.provider](https://github.com/o3co/auth.provider), paths in *Verifier reads* to [o3co/auth.policy-verifier](https://github.com/o3co/auth.policy-verifier), and `tests/…` paths to this repo. ## Claims that cross @@ -17,7 +19,7 @@ The RFC 9068 JWT access token. The provider assembles claims in `generateToken` | `azp` | The *authenticated* client id, not the raw body `client_id` (D-6 in `oauth/src/grants/authorization.mts`). | `PayloadSubjectIdCollector` → `ATTR_CLIENT_ID`. | Which client the token was issued through. | | `aud` | The RFC 8707 `resource` parameter echoed back as the audience; dropped on refresh when the parameter is not repeated (§2.2). | Pinned by jose verification — `oauth.jwt.audience` / `OAUTH_JWT_AUDIENCE`. | The resource server the token is addressed to. The E2E value `https://api.e2e.test` appears in `tests/provider/clients.yaml` (`allowedAudiences`) *and* the verifier's env for exactly this reason. | | `iss` | Deployment-configured issuer; required, never request-derived. | Pinned by jose verification — `oauth.jwt.issuer` / `OAUTH_JWT_ISSUER`. | Deployment identity. | -| `typ` (header) | `at+jwt` on access tokens (RFC 9068), `rt+jwt` on refresh tokens, `id+jwt` on id_tokens. | `oauth.jwt.tokenType`, default `at+jwt`; an `application/` prefix on either side is ignored when comparing. | The **only** discriminator between the three token kinds — the pin is what keeps a refresh or id token signed with the same key from passing `/verify`. There is no claim-level fallback check. | +| `typ` (header) | `at+jwt` on access tokens (RFC 9068), `rt+jwt` on refresh tokens, `id+jwt` on ID tokens (wire name `id_token`). | `oauth.jwt.tokenType`, default `at+jwt`; an `application/` prefix on either side is ignored when comparing. | The **only** discriminator between the three token kinds — the pin is what keeps a refresh or id token signed with the same key from passing `/verify`. There is no claim-level fallback check. | | `exp` / `iat` | Always stamped (`core/src/grants/token.mts`). | Both required (`tokenAuthenticator`'s required-claims check + the always-set `maxTokenAgeSeconds` bound); a token without `exp` is refused as a permanent credential. | Lifetime. The verifier additionally caps the issuer's `exp` rather than trusting issuer discipline. | ## Claims that do NOT cross