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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion resource/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion resource/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion resource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 33 additions & 3 deletions resource/src/verify-token.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
27 changes: 24 additions & 3 deletions resource/src/verify-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +188,14 @@ const TYP_TO_KIND: Record<string, TokenKind> = {
[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<TokenKind, string> = {
agent: 'invalid_agent_token',
person: 'invalid_person_token',
Expand Down Expand Up @@ -376,7 +390,7 @@ export async function verifyToken(options: VerifyTokenOptions): Promise<Verified
const key = await importJWK(signingKey, header.alg as string)
await jwtVerify(rawJwt, key, {
algorithms: [header.alg as string],
clockTolerance: clockToleranceSeconds,
clockTolerance: 0,
typ: typ as string,
currentDate: new Date(now * 1000),
})
Expand All @@ -398,8 +412,15 @@ export async function verifyToken(options: VerifyTokenOptions): Promise<Verified

// 5. Issuance time. jose checks `exp` and `nbf` but not a future `iat`, so
// that one is applied here — after the signature, for the same reason.
// An `iat` further ahead of our clock than the window is not a defect
// in the token: the issuer's clock and ours disagree. Reported under
// its own code so a caller does not refresh — a fresh token from the
// same issuer carries the same skew — but waits the difference out.
if (iat > 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.
Expand Down