diff --git a/package-lock.json b/package-lock.json index 1cd5b0d..e984907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4213,7 +4213,7 @@ }, "resource": { "name": "@aauth/resource", - "version": "2.3.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "@aauth/interaction-code": "^0.1.0", diff --git a/resource/README.md b/resource/README.md index 50e5843..cb358b2 100644 --- a/resource/README.md +++ b/resource/README.md @@ -69,7 +69,8 @@ the values compare. | `unsupported_token_type` | `typ` is not an AAuth token type | | `token_type_not_accepted` | Recognized, but not allowed at this call site — including a person token where an auth token is required | | `invalid_agent_token` / `invalid_person_token` / `invalid_auth_token` | Structure, discovery or signature failed | -| `token_expired` | `exp` is in the past, on a token whose issuer signature verified | +| `token_expired` | `exp` is in the past by this verifier's clock, with no tolerance, on a token whose issuer signature verified | +| `clock_skew` | `iat` is further ahead of this verifier's clock than `clockToleranceSeconds` (default 60). The issuer's clock, not the token, is at fault: a fresh token carries the same skew, so the presenter waits the difference out (the response `Date` header is the verifier's clock). Answer `401` with `Signature-Error: error=clock_skew` | | `aud_mismatch` | `aud` is not this resource | | `key_binding_failed` | `cnf.jwk` is not the key that signed the request | | `revoked_jwt` | The issuer revoked this token (`revocation` was supplied and holds its `(iss, jti)`). Answer `401` with `Signature-Error: error=revoked_jwt` | @@ -89,6 +90,13 @@ a forgery reporting it would send the caller off to refresh a token that was never the problem. Changed in 2.2.0 — earlier versions checked `exp` before resolving the issuer's JWKS. +**Changed in 2.4.0.** `exp` is judged against this verifier's clock with no +tolerance, per AAuth -11 §Expiry and the Refresh Margin: the agent refreshes +at least five minutes before expiry, and a verifier that allowed for skew on +`exp` would only let a token that one hop accepted fail at the next. +`clockToleranceSeconds` now bounds `iat` alone, and an `iat` beyond it is +`clock_skew` rather than `invalid_*_token`. + ## Challenging ```ts diff --git a/resource/package.json b/resource/package.json index 24be7ec..e371dc4 100644 --- a/resource/package.json +++ b/resource/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/resource", - "version": "2.3.0", + "version": "2.4.0", "description": "AAuth resource-side reference implementation: token verification, resource tokens, R3 documents and per-call proposals, challenge headers, interaction management", "type": "module", "exports": { diff --git a/resource/src/index.ts b/resource/src/index.ts index f77e1bb..fd154ef 100644 --- a/resource/src/index.ts +++ b/resource/src/index.ts @@ -28,7 +28,7 @@ export type { } from './challenge.js' // --- Token verification --- -export { verifyToken } from './verify-token.js' +export { verifyToken, CLOCK_SKEW } from './verify-token.js' export { AAuthTokenError, R3Error } from './errors.js' export { clearMetadataCache, discoverJwks } from './jwks.js' export type { FetchLike } from './jwks.js' diff --git a/resource/src/verify-token.test.ts b/resource/src/verify-token.test.ts index e811964..d48bb6c 100644 --- a/resource/src/verify-token.test.ts +++ b/resource/src/verify-token.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint } from 'jose' -import { verifyToken, AAuthTokenError, clearMetadataCache } from './index.js' +import { verifyToken, AAuthTokenError, clearMetadataCache, CLOCK_SKEW } from './index.js' import type { VerifiedPersonToken, VerifiedAuthToken, VerifiedAgentToken } from './index.js' import { createTestKeys, signTestJwt, mockJwksFetch, RESOURCE, PS, AP, MISSION_S256, @@ -385,12 +385,42 @@ describe('time and key discovery', () => { ) }) - it('rejects an iat in the future', async () => { + it('reports an iat beyond the window as clock_skew, not an invalid token', async () => { const future = Math.floor(Date.now() / 1000) + 7200 const jwt = await signTestJwt( keys.issuerPrivate, 'aa-person+jwt', { ...personClaims(), iat: future, exp: future + 600 }, ) - await expect(verifyToken(opts(jwt))).rejects.toThrow('iat is in the future') + try { + await verifyToken(opts(jwt)) + expect.unreachable() + } catch (err) { + expect(err).toBeInstanceOf(AAuthTokenError) + expect((err as AAuthTokenError).code).toBe(CLOCK_SKEW) + expect((err as AAuthTokenError).message).toMatch(/ahead of this verifier/) + } + }) + + it('accepts an iat inside the window', async () => { + const now = Math.floor(Date.now() / 1000) + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', { ...personClaims(), iat: now + 30, exp: now + 3600 }, + ) + await expect(verifyToken(opts(jwt))).resolves.toMatchObject({ type: 'person' }) + }) + + it('judges exp with no tolerance', async () => { + // Five seconds past: a 60s tolerance would have accepted it. The agent + // refreshes before expiry; the verifier does not allow for it. + const now = Math.floor(Date.now() / 1000) + const jwt = await signTestJwt( + keys.issuerPrivate, 'aa-person+jwt', { ...personClaims(), iat: now - 3600, exp: now - 5 }, + ) + try { + await verifyToken(opts(jwt)) + expect.unreachable() + } catch (err) { + expect((err as AAuthTokenError).code).toBe('token_expired') + } }) it('rejects an iss that is not a server identifier', async () => { diff --git a/resource/src/verify-token.ts b/resource/src/verify-token.ts index c999c44..54f7714 100644 --- a/resource/src/verify-token.ts +++ b/resource/src/verify-token.ts @@ -35,7 +35,13 @@ export interface VerifyTokenOptions { accept: readonly TokenKind[] /** Injectable fetch, for Workers bindings and tests. Defaults to global fetch. */ fetch?: FetchLike - /** Seconds of clock skew tolerated on `exp` and `iat`. Default 60. */ + /** + * How far a token's `iat` may be ahead of this verifier's clock before it + * is refused with `clock_skew`. Default 60, the same window the HTTP + * signature's `created` gets. `exp` is judged against this verifier's clock + * with no tolerance (AAuth Protocol §Expiry and the Refresh Margin): the + * agent refreshes before expiry; the verifier does not allow for it. + */ clockToleranceSeconds?: number /** Override "now", in seconds since the epoch. For tests. */ now?: number @@ -182,6 +188,14 @@ const TYP_TO_KIND: Record = { [TOKEN_TYP.auth]: 'auth', } +/** + * The code `verifyToken` throws — and the `Signature-Error` value a resource + * returns with `401` — when a token's `iat` is further ahead of the + * verifier's clock than `clockToleranceSeconds`. The issuer's clock, not the + * token, is at fault; the presenter waits rather than refreshes. + */ +export const CLOCK_SKEW = 'clock_skew' + const ERROR_CODE: Record = { agent: 'invalid_agent_token', person: 'invalid_person_token', @@ -376,7 +390,7 @@ export async function verifyToken(options: VerifyTokenOptions): Promise now + clockToleranceSeconds) { - throw new AAuthTokenError(code, 'Token iat is in the future') + throw new AAuthTokenError( + CLOCK_SKEW, + `Token iat is ${iat - now}s ahead of this verifier's clock (window ${clockToleranceSeconds}s)`, + ) } // 6. Audience. An agent token has none; a person or auth token names us.