diff --git a/docs/claims-contract.ja.md b/docs/claims-contract.ja.md index 338cc8c..8d5f79c 100644 --- a/docs/claims-contract.ja.md +++ b/docs/claims-contract.ja.md @@ -19,7 +19,7 @@ RFC 9068 JWT access token。provider は `generateToken`(auth.provider の `pa | `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(wire 名 `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`)は auth.provider v0.10.0 以降は標準の `JWT`(それ以前は非標準の `id+jwt` で、`typ` を厳格に検証する外部 RP に弾かれていた)。検証側は auth.provider#402 が閉じるまでの移行期間、両方の綴りを受理する。 | `oauth.jwt.tokenType`、default `at+jwt`。比較時に `application/` prefix は無視。 | 3 種の token を区別する **唯一の** 判別子 — このピンが、同じ鍵で署名された refresh / id token が `/verify` を通ることを防いでいる。防いでいる実体は **`at+jwt` と互いに素であること** であり、`JWT` は `id+jwt` と同様にこれを満たす。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 bc09aa7..fb9d663 100644 --- a/docs/claims-contract.md +++ b/docs/claims-contract.md @@ -19,7 +19,7 @@ Path convention for the tables below: paths in the *Provider writes* column are | `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 (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. | +| `typ` (header) | `at+jwt` on access tokens (RFC 9068), `rt+jwt` on refresh tokens, and the standard `JWT` on ID tokens (wire name `id_token`) since auth.provider v0.10.0 — it was the nonstandard `id+jwt` before, which strict external RPs rejected; verification accepts both spellings during the window auth.provider#402 closes. | `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`. What does the keeping-out is being **disjoint from `at+jwt`**, which `JWT` satisfies exactly as `id+jwt` did. 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 diff --git a/tests/abac/index.test.js b/tests/abac/index.test.js index 03706b7..727d9d9 100644 --- a/tests/abac/index.test.js +++ b/tests/abac/index.test.js @@ -101,7 +101,10 @@ describe('ABAC: only access tokens are decision inputs', () => { it('rejects the id_token from the same grant', async () => { const res = await verify({ token: projectGrant.id_token }); - expect(decodeJwt(projectGrant.id_token).header.typ).toBe('id+jwt'); + // `JWT` since auth.provider#394 (v0.10.0). What keeps an id_token out of + // `/verify` is being disjoint from `at+jwt`, which the standard value + // satisfies identically — so the refusal below is unchanged. + expect(decodeJwt(projectGrant.id_token).header.typ).toBe('JWT'); expect(res.status).toBe(401); expect(res.body.decision).toBe('deny'); expect(res.body.code).toBe('invalid_token'); diff --git a/tests/token-flow/index.test.js b/tests/token-flow/index.test.js index ab9d349..80753fd 100644 --- a/tests/token-flow/index.test.js +++ b/tests/token-flow/index.test.js @@ -89,7 +89,15 @@ describe('Real grant path: login -> /authorize (PKCE) -> /token', () => { // (see the negative tests in tests/abac). Pinning all three here means // a provider-side change to any of them fails on this repo's CI rather // than silently widening what /verify accepts. - expect(decodeJwt(grant.id_token).header.typ).toBe('id+jwt'); + // + // The id_token stamps the standard `JWT` as of auth.provider#394 + // (v0.10.0): the nonstandard `id+jwt` failed strict external RPs and + // bought nothing, because what keeps an id_token out of `/verify` is + // being disjoint from RFC 9068's `at+jwt` — which `JWT` satisfies just + // as well. Verification accepts both spellings during the migration + // window auth.provider#402 closes; this pins what the provider MINTS, + // so it is the standard value with no dual accept. + expect(decodeJwt(grant.id_token).header.typ).toBe('JWT'); expect(decodeJwt(grant.refresh_token).header.typ).toBe('rt+jwt'); });