From e50f3c492ca7a3945a03d6117f7aed21a03770ec Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 9 Sep 2026 15:31:27 +0100 Subject: [PATCH] A negative token_lifetime must still mint an expired auth token The presented-token clamp (never outlive the token the agent presented) floors the lifetime at 1s so a nearly-expired presented token cannot produce a negative one. That floor also caught the mock switch a resource-side suite uses to mint an ALREADY-expired auth token and drive its challenge-on-401 path: token_lifetime: -60 issued a token valid for one second, the challenge never fired, and the suite failed with a 200 where it expected a 401. Apply the floor only when the configured lifetime is positive. 233 passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CRdau7tgZa1tPVzUdyNrHc --- package-lock.json | 4 ++-- package.json | 2 +- src/aauth/issue-auth-token.js | 10 +++++++++- test/aauth/token.identity.spec.js | 23 +++++++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60692b7..4cb5170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hellocoop/mockin", - "version": "3.1.0", + "version": "3.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hellocoop/mockin", - "version": "3.1.0", + "version": "3.1.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/package.json b/package.json index 2590780..6b82fb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hellocoop/mockin", "private": false, - "version": "3.1.0", + "version": "3.1.1", "description": "Hellō Mock Login OpenID Connect Server", "engines": { "node": ">=22" diff --git a/src/aauth/issue-auth-token.js b/src/aauth/issue-auth-token.js index 1172f31..8baa770 100644 --- a/src/aauth/issue-auth-token.js +++ b/src/aauth/issue-auth-token.js @@ -115,7 +115,15 @@ export async function issueAuthToken({ const cfg = getConfig() const iat = Math.floor(Date.now() / 1000) let lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL) - if (Number.isFinite(presented_exp)) { + if (Number.isFinite(presented_exp) && lifetime > 0) { + // Never outlive the presented token (-11 §Auth Token Structure), and + // never let a presented token that is nearly expired turn a positive + // lifetime into a negative one — one second is still a live token. + // + // The floor applies to the clamp only. A NEGATIVE `token_lifetime` is + // the mock switch for minting an already-expired token, which the + // challenge-on-401 suites depend on; flooring that to 1 would issue a + // valid token and quietly break them. lifetime = Math.max(1, Math.min(lifetime, presented_exp - iat)) } const { identity, resource } = classifyScopes(scope) diff --git a/test/aauth/token.identity.spec.js b/test/aauth/token.identity.spec.js index 4e05835..5f81ead 100644 --- a/test/aauth/token.identity.spec.js +++ b/test/aauth/token.identity.spec.js @@ -139,6 +139,29 @@ describe('AAuth auth_token_endpoint — identity flow (no R3)', function () { expect(claims.exp - claims.iat).to.equal(3600) }) + it('a negative token_lifetime still mints an expired token', async function () { + // The switch exists so a resource-side suite can drive its + // challenge-on-401 path. Clamping to the presented token's exp must + // not floor a deliberately negative lifetime up to 1s — that issues a + // live token and the challenge never fires. + // Mint the person token FIRST — token_lifetime shapes it too, and an + // expired presented token is a different (correct) 400. + const { agentToken, body } = await personAndResourceToken(fastify, { + resource: { scope: 'openid' }, + }) + await fastify.inject({ + method: 'PUT', + url: '/mock/aauth', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ token_lifetime: -60 }), + }) + const response = await postAuthToken(fastify, { body, agentToken }) + expect(response.statusCode).to.equal(200) + const claims = decodeJwt(response.json().auth_token) + expect(claims.exp - claims.iat).to.equal(-60) + expect(claims.exp).to.be.below(Math.floor(Date.now() / 1000)) + }) + it('never outlives the presented token (agent token 600s → auth token ≤ 600s)', async function () { const { response, personClaims } = await postToken({ person: { agentToken: await mintAgentToken({ ttl: 600 }) },