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
75 changes: 75 additions & 0 deletions apps/identity-service/src/data-rights-recent-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>
).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');
});
});
37 changes: 37 additions & 0 deletions apps/identity-service/src/oauth-http-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading