diff --git a/apps/identity-service/src/data-rights-authenticated-application.test.ts b/apps/identity-service/src/data-rights-authenticated-application.test.ts new file mode 100644 index 00000000..087a187f --- /dev/null +++ b/apps/identity-service/src/data-rights-authenticated-application.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +const SESSION_BODY = Object.freeze({ + sessionId: '11111111-1111-4111-8111-111111111111', + userId: '22222222-2222-4222-8222-222222222222', + workspaceId: '33333333-3333-4333-8333-333333333333', + authenticatedAt: '2026-08-09T17:55:00.000Z', + createdAt: '2026-08-09T17:56:00.000Z', + expiresAt: '2026-08-10T17:56:00.000Z', +}); + +interface AuthenticatedApplicationConstructor { + new ( + sessions: { + introspectSession(cookieHeader: string | undefined): Promise<{ + statusCode: 200; + body: typeof SESSION_BODY; + }>; + }, + dataRights: { + exportWorkspace(context: { + readonly workspaceId: string; + readonly actorUserId: string; + }): Promise; + }, + options: { + readonly now: () => Date; + readonly maximumAgeMs: number; + }, + ): { + exportWorkspace(cookieHeader: string | undefined): Promise; + }; +} + +async function applicationConstructor(): Promise { + const modulePath = './data-rights-authenticated-application'; + const module = (await import(modulePath).catch(() => ({}))) as Readonly< + Record + >; + expect(typeof module.AuthenticatedDataRightsApplication).toBe('function'); + return module.AuthenticatedDataRightsApplication as AuthenticatedApplicationConstructor; +} + +describe('AuthenticatedDataRightsApplication', () => { + it('derives export ownership only from the authenticated recent session', async () => { + const AuthenticatedDataRightsApplication = await applicationConstructor(); + const contexts: unknown[] = []; + const sessions = { + async introspectSession(cookieHeader: string | undefined) { + expect(cookieHeader).toBe('life_os_session=opaque-session'); + return { statusCode: 200 as const, body: SESSION_BODY }; + }, + }; + const dataRights = { + async exportWorkspace(context: { + readonly workspaceId: string; + readonly actorUserId: string; + }) { + contexts.push(context); + return { schemaVersion: 'life-os.data-export.v1' }; + }, + }; + const application = new AuthenticatedDataRightsApplication( + sessions, + dataRights, + { + now: () => new Date('2026-08-09T18:00:00.000Z'), + maximumAgeMs: 10 * 60 * 1000, + }, + ); + + await expect( + application.exportWorkspace('life_os_session=opaque-session'), + ).resolves.toEqual({ schemaVersion: 'life-os.data-export.v1' }); + expect(contexts).toEqual([ + { + workspaceId: SESSION_BODY.workspaceId, + actorUserId: SESSION_BODY.userId, + }, + ]); + }); +}); diff --git a/apps/identity-service/src/data-rights-authenticated-application.ts b/apps/identity-service/src/data-rights-authenticated-application.ts new file mode 100644 index 00000000..2609312a --- /dev/null +++ b/apps/identity-service/src/data-rights-authenticated-application.ts @@ -0,0 +1,58 @@ +import type { DataRightsWorkspaceContext } from './data-rights'; +import { requireRecentAuthentication } from './oauth-http-boundary'; + +interface SessionView { + readonly userId: string; + readonly workspaceId: string; + readonly authenticatedAt: string; +} + +interface SessionIntrospectionApplication { + introspectSession(cookieHeader: string | undefined): Promise<{ + readonly statusCode: number; + readonly body: SessionView; + }>; +} + +interface DataRightsExportApplication { + exportWorkspace(context: DataRightsWorkspaceContext): Promise; +} + +interface RecentAuthenticationOptions { + readonly now: () => Date; + readonly maximumAgeMs: number; +} + +/** + * Establishes the authenticated application boundary for data-rights exports. + * Workspace and actor ownership are derived exclusively from the opaque session, + * and the request is rejected before data-rights work when authentication is stale. + */ +export class AuthenticatedDataRightsApplication { + constructor( + private readonly sessions: SessionIntrospectionApplication, + private readonly dataRights: DataRightsExportApplication, + private readonly options: RecentAuthenticationOptions, + ) {} + + /** + * Exports the session-owned workspace after enforcing the configured recent-authentication window. + */ + async exportWorkspace(cookieHeader: string | undefined): Promise { + const session = await this.sessions.introspectSession(cookieHeader); + if (session.statusCode !== 200) { + throw new Error('Authentication is required'); + } + requireRecentAuthentication({ + authenticatedAt: session.body.authenticatedAt, + now: this.options.now(), + maximumAgeMs: this.options.maximumAgeMs, + }); + return this.dataRights.exportWorkspace( + Object.freeze({ + workspaceId: session.body.workspaceId, + actorUserId: session.body.userId, + }), + ); + } +} 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'); + }); +}); 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. */