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
Original file line number Diff line number Diff line change
@@ -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<unknown>;
},
options: {
readonly now: () => Date;
readonly maximumAgeMs: number;
},
): {
exportWorkspace(cookieHeader: string | undefined): Promise<unknown>;
};
}

async function applicationConstructor(): Promise<AuthenticatedApplicationConstructor> {
const modulePath = './data-rights-authenticated-application';
const module = (await import(modulePath).catch(() => ({}))) as Readonly<
Record<string, unknown>
>;
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,
},
]);
});
});
58 changes: 58 additions & 0 deletions apps/identity-service/src/data-rights-authenticated-application.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
}

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<unknown> {
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,
}),
);
}
}
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