From d3f6d19feea8c7b7d578334358b46e36bf997fc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 02:56:17 +0900 Subject: [PATCH 1/2] test(identity): define recent authentication gate for data rights --- .../src/data-rights-recent-auth.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/identity-service/src/data-rights-recent-auth.test.ts diff --git a/apps/identity-service/src/data-rights-recent-auth.test.ts b/apps/identity-service/src/data-rights-recent-auth.test.ts new file mode 100644 index 00000000..93317b6a --- /dev/null +++ b/apps/identity-service/src/data-rights-recent-auth.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import * as oauthBoundary from './oauth-http-boundary'; + +type RecentAuthenticationGate = (input: { + readonly authenticatedAt: string; + readonly now: Date; + readonly maximumAgeMs: number; +}) => string; + +function recentAuthenticationGate(): RecentAuthenticationGate { + const candidate = ( + oauthBoundary as unknown as Readonly> + ).requireRecentAuthentication; + expect(typeof candidate).toBe('function'); + return candidate as RecentAuthenticationGate; +} + +describe('data-rights recent authentication gate', () => { + it('accepts an authentication instant at the exact maximum age boundary', () => { + const requireRecentAuthentication = recentAuthenticationGate(); + + expect( + requireRecentAuthentication({ + authenticatedAt: '2026-08-09T17:50:00.000Z', + now: new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 10 * 60 * 1000, + }), + ).toBe('2026-08-09T17:50:00.000Z'); + }); + + it('rejects a stale authentication instant even when the session itself is still valid', () => { + const requireRecentAuthentication = recentAuthenticationGate(); + + expect(() => + requireRecentAuthentication({ + authenticatedAt: '2026-08-09T17:49:59.999Z', + now: new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 10 * 60 * 1000, + }), + ).toThrow('Recent authentication is required'); + }); + + it('fails closed on future, malformed, or invalid policy timestamps', () => { + const requireRecentAuthentication = recentAuthenticationGate(); + + expect(() => + requireRecentAuthentication({ + authenticatedAt: '2026-08-09T18:00:00.001Z', + now: new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 10 * 60 * 1000, + }), + ).toThrow('Authentication provenance is invalid'); + expect(() => + requireRecentAuthentication({ + authenticatedAt: 'not-an-instant', + now: new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 10 * 60 * 1000, + }), + ).toThrow('Authentication provenance is invalid'); + expect(() => + requireRecentAuthentication({ + authenticatedAt: '2026-08-09T17:55:00.000Z', + now: new Date('invalid'), + maximumAgeMs: 10 * 60 * 1000, + }), + ).toThrow('Recent authentication policy is invalid'); + expect(() => + requireRecentAuthentication({ + authenticatedAt: '2026-08-09T17:55:00.000Z', + now: new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 0, + }), + ).toThrow('Recent authentication policy is invalid'); + }); +}); From a0f7a7314dfdb6a3fe7ed048dc9c09f976b71536 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 03:00:59 +0900 Subject: [PATCH 2/2] feat(identity): enforce recent authentication policy --- .../src/oauth-http-boundary.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/identity-service/src/oauth-http-boundary.ts b/apps/identity-service/src/oauth-http-boundary.ts index cef4de2c..d8d35127 100644 --- a/apps/identity-service/src/oauth-http-boundary.ts +++ b/apps/identity-service/src/oauth-http-boundary.ts @@ -91,6 +91,43 @@ function requirePositiveInteger(value: number, message: string): number { return value; } +/** + * Requires authentication provenance to fall within one bounded recent-authentication window. + */ +export function requireRecentAuthentication(input: { + readonly authenticatedAt: string; + readonly now: Date; + readonly maximumAgeMs: number; +}): string { + if ( + !(input.now instanceof Date) || + !Number.isFinite(input.now.getTime()) || + !Number.isSafeInteger(input.maximumAgeMs) || + input.maximumAgeMs <= 0 + ) { + throw new Error('Recent authentication policy is invalid'); + } + + if (typeof input.authenticatedAt !== 'string') { + throw new Error('Authentication provenance is invalid'); + } + const authenticatedAtMs = Date.parse(input.authenticatedAt); + if (!Number.isFinite(authenticatedAtMs)) { + throw new Error('Authentication provenance is invalid'); + } + const canonicalAuthenticatedAt = new Date(authenticatedAtMs).toISOString(); + if ( + canonicalAuthenticatedAt !== input.authenticatedAt || + authenticatedAtMs > input.now.getTime() + ) { + throw new Error('Authentication provenance is invalid'); + } + if (input.now.getTime() - authenticatedAtMs > input.maximumAgeMs) { + throw new Error('Recent authentication is required'); + } + return canonicalAuthenticatedAt; +} + /** * Parses a bounded Cookie header without decoding or accepting duplicate names. */