From 7844cc9dc2b0fca5e7c08794a0ddb97e0bc79aa0 Mon Sep 17 00:00:00 2001 From: yoshi49535 Date: Sat, 29 Aug 2026 12:05:28 +0900 Subject: [PATCH] fix(e2e): the id_token typ is the standard JWT, not id+jwt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.provider#394 (shipped in v0.10.0) changed the id_token's `typ` header from the nonstandard `id+jwt` to the standard `JWT`. That landed on the provider's develop after this repo's last E2E run, so the suite had never seen it and the first run after the release cut failed on the pin. The pin itself stays — it is the only discriminator between the three token kinds, and the negative tests in tests/abac depend on it. What keeps an id_token out of /verify is being disjoint from RFC 9068's `at+jwt`, and `JWT` satisfies that exactly as `id+jwt` did, so the abac refusals are unchanged. Pinning the MINTED value, so no dual accept here: the verifier accepts both spellings during the migration window, but the provider stamps one, and this suite exists to notice when that one changes. docs/claims-contract{,.ja}.md carried the same `id+jwt` statement and are updated with the window and the reason. Co-Authored-By: Claude Fable 5 --- docs/claims-contract.ja.md | 2 +- docs/claims-contract.md | 2 +- tests/abac/index.test.js | 5 ++++- tests/token-flow/index.test.js | 10 +++++++++- 4 files changed, 15 insertions(+), 4 deletions(-) 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'); });