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 }) },