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
292 changes: 292 additions & 0 deletions apps/identity-service/src/oauth-http.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import type { AddressInfo } from 'node:net';
import { INestApplication, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
InMemoryOAuthTransactionRepository,
InMemorySessionRepository,
OAuthTransactionService,
SessionService,
} from './auth-security';
import { IdentityService, InMemoryIdentityRepository } from './identity-domain';
import {
OAuthCallbackApplication,
type OAuthCallbackAuditEvent,
} from './oauth-callback-application';
import { OAuthHttpApplication } from './oauth-http-application';
import {
OAUTH_CALLBACK_APPLICATION,
OAUTH_HTTP_APPLICATION,
OAuthHttpController,
} from './oauth-http-controller';

const NOW = new Date('2026-08-03T15:00:00.000Z');
const WEB_ORIGIN = 'https://app.example.test';
const GOOGLE_REDIRECT_URI =
'https://identity.example.test/v1/auth/google/callback';
const GITHUB_REDIRECT_URI =
'https://identity.example.test/v1/auth/github/callback';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

interface TestHarness {
app: INestApplication;
baseUrl: string;
auditEvents: OAuthCallbackAuditEvent[];
googleAuthenticate: ReturnType<typeof vi.fn>;
githubAuthenticate: ReturnType<typeof vi.fn>;
}

const activeApplications: INestApplication[] = [];

function cookiePair(setCookie: string | null): string {
if (!setCookie) {
throw new Error('Expected Set-Cookie header');
}
return setCookie.split(';', 1)[0] as string;
}

function callbackState(location: string | null): string {
if (!location) {
throw new Error('Expected authorization redirect');
}
const state = new URL(location).searchParams.get('state');
if (!state) {
throw new Error('Expected OAuth state');
}
return state;
}

async function request(
baseUrl: string,
path: string,
init: RequestInit = {},
): Promise<Response> {
return await fetch(`${baseUrl}${path}`, {
...init,
redirect: 'manual',
});
}

async function createHarness(): Promise<TestHarness> {
const transactions = new OAuthTransactionService(
new InMemoryOAuthTransactionRepository(),
{ now: () => NOW },
);
const sessions = new SessionService(new InMemorySessionRepository(), {
now: () => NOW,
});
const identities = new IdentityService(new InMemoryIdentityRepository());
const auditEvents: OAuthCallbackAuditEvent[] = [];
const googleAuthenticate = vi.fn(async () => ({
provider: 'google' as const,
subject: 'google-subject-integration',
issuer: 'https://accounts.google.com' as const,
email: 'integration@example.test',
emailVerified: true,
displayName: 'Google Integration User',
}));
const githubAuthenticate = vi.fn(async () => ({
provider: 'github' as const,
providerSubject: '9007199254740993',
displayName: 'GitHub Integration User',
verifiedEmail: 'integration@example.test',
}));
const httpApplication = new OAuthHttpApplication(transactions, sessions, {
providers: {
google: {
clientId: 'google-integration-client',
redirectUri: GOOGLE_REDIRECT_URI,
},
github: {
clientId: 'github-integration-client',
redirectUri: GITHUB_REDIRECT_URI,
},
},
webOrigin: WEB_ORIGIN,
});
const callbackApplication = new OAuthCallbackApplication(
transactions,
identities,
sessions,
{
google: { authenticateAuthorizationCode: googleAuthenticate },
github: { authenticateAuthorizationCode: githubAuthenticate },
},
{
record(event): void {
auditEvents.push({ ...event });
},
},
{ webOrigin: WEB_ORIGIN, now: () => NOW },
);

@Module({
controllers: [OAuthHttpController],
providers: [
{ provide: OAUTH_HTTP_APPLICATION, useValue: httpApplication },
{ provide: OAUTH_CALLBACK_APPLICATION, useValue: callbackApplication },
],
})
class OAuthHttpIntegrationModule {}

const app = await NestFactory.create(OAuthHttpIntegrationModule, {
logger: false,
});
await app.listen(0, '127.0.0.1');
activeApplications.push(app);
const address = app.getHttpServer().address() as AddressInfo;
return {
app,
baseUrl: `http://127.0.0.1:${address.port}`,
auditEvents,
googleAuthenticate,
githubAuthenticate,
};
}

afterEach(async () => {
await Promise.all(activeApplications.splice(0).map((app) => app.close()));
});

describe('OAuth HTTP integration', () => {
it('completes the Google browser-session lifecycle over HTTP', async () => {
const harness = await createHarness();

const start = await request(harness.baseUrl, '/v1/auth/google/start');
expect(start.status).toBe(303);
expect(start.headers.get('cache-control')).toBe('no-store');
expect(start.headers.get('location')).toContain(
'https://accounts.google.com/o/oauth2/v2/auth',
);
const browserSetCookie = start.headers.get('set-cookie');
expect(browserSetCookie).toContain('Path=/v1/auth');
expect(browserSetCookie).toContain('HttpOnly; Secure; SameSite=Lax');
const browserCookie = cookiePair(browserSetCookie);
const state = callbackState(start.headers.get('location'));

const callback = await request(
harness.baseUrl,
`/v1/auth/google/callback?code=google-code&state=${encodeURIComponent(state)}`,
{ headers: { cookie: browserCookie } },
);
expect(callback.status).toBe(303);
expect(callback.headers.get('cache-control')).toBe('no-store');
expect(callback.headers.get('location')).toBe(
'https://app.example.test/auth/complete',
);
const correlationId = callback.headers.get('x-correlation-id');
expect(correlationId).toMatch(UUID_V4_PATTERN);
const sessionSetCookie = callback.headers.get('set-cookie');
expect(sessionSetCookie).toContain('Path=/');
expect(sessionSetCookie).toContain('HttpOnly; Secure; SameSite=Lax');
const sessionCookie = cookiePair(sessionSetCookie);
const sessionToken = sessionCookie.split('=', 2)[1] as string;

const session = await request(harness.baseUrl, '/v1/session', {
headers: { cookie: sessionCookie },
});
expect(session.status).toBe(200);
expect(session.headers.get('cache-control')).toBe('no-store');
const sessionBody = await session.json();
expect(sessionBody).toMatchObject({
sessionId: expect.stringMatching(UUID_V4_PATTERN),
userId: expect.stringMatching(UUID_V4_PATTERN),
workspaceId: expect.stringMatching(UUID_V4_PATTERN),
});
const serializedSession = JSON.stringify(sessionBody);
expect(serializedSession).not.toContain(sessionToken);
expect(serializedSession).not.toContain('google-code');
expect(serializedSession).not.toContain(state);

const replay = await request(
harness.baseUrl,
`/v1/auth/google/callback?code=google-code&state=${encodeURIComponent(state)}`,
{ headers: { cookie: browserCookie } },
);
expect(replay.status).toBe(400);
expect(await replay.json()).toMatchObject({
status: 400,
code: 'oauth_callback_failed',
});

const logout = await request(harness.baseUrl, '/v1/auth/logout', {
method: 'POST',
headers: { cookie: sessionCookie },
});
expect(logout.status).toBe(204);
expect(logout.headers.get('set-cookie')).toContain(
'life_os_session=deleted; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax',
);

const repeatedLogout = await request(harness.baseUrl, '/v1/auth/logout', {
method: 'POST',
headers: { cookie: sessionCookie },
});
expect(repeatedLogout.status).toBe(204);

const revokedSession = await request(harness.baseUrl, '/v1/session', {
headers: { cookie: sessionCookie },
});
expect(revokedSession.status).toBe(401);
expect(await revokedSession.json()).toMatchObject({
status: 401,
code: 'authentication_required',
});
expect(harness.googleAuthenticate).toHaveBeenCalledTimes(1);
expect(harness.auditEvents).toEqual([
expect.objectContaining({
provider: 'google',
outcome: 'success',
correlationId,
}),
{
provider: 'google',
outcome: 'failure',
correlationId: expect.stringMatching(UUID_V4_PATTERN),
},
]);
});

it('binds GitHub callbacks to the initiating browser', async () => {
const harness = await createHarness();
const start = await request(harness.baseUrl, '/v1/auth/github/start');
const browserCookie = cookiePair(start.headers.get('set-cookie'));
const state = callbackState(start.headers.get('location'));
const wrongBrowserCookie = `life_os_oauth_browser=${'x'.repeat(43)}`;
const callbackPath = `/v1/auth/github/callback?code=github-code&state=${encodeURIComponent(state)}`;

const rejected = await request(harness.baseUrl, callbackPath, {
headers: { cookie: wrongBrowserCookie },
});
expect(rejected.status).toBe(400);
expect(harness.githubAuthenticate).not.toHaveBeenCalled();

const accepted = await request(harness.baseUrl, callbackPath, {
headers: {
cookie: browserCookie,
'x-correlation-id': 'github-integration-correlation',
},
});
expect(accepted.status).toBe(303);
expect(accepted.headers.get('location')).toBe(
'https://app.example.test/auth/complete',
);
expect(accepted.headers.get('x-correlation-id')).toBe(
'github-integration-correlation',
);
expect(harness.githubAuthenticate).toHaveBeenCalledTimes(1);
expect(harness.auditEvents).toEqual([
{
provider: 'github',
outcome: 'failure',
correlationId: expect.stringMatching(UUID_V4_PATTERN),
},
expect.objectContaining({
provider: 'github',
outcome: 'success',
correlationId: 'github-integration-correlation',
}),
]);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
18 changes: 18 additions & 0 deletions docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# OAuth HTTP Integration Slice

## Goal

Verify the complete Google and GitHub browser-session lifecycle through the production NestJS controller boundary without contacting external providers.

## Changes

1. Start a real ephemeral NestJS HTTP server with the production OAuth controller and in-memory domain repositories.
2. Exercise Google authorization start, callback completion, secure session cookie issuance, session introspection, replay rejection, logout, and revoked-session rejection.
3. Exercise GitHub cross-browser state rejection before provider access, followed by successful completion from the bound browser.
4. Assert fixed-origin redirects, no-store responses, secure cookie attributes, credential-free response bodies, correlation IDs, and structured audit outcomes.

## Validation

- formatting, lint, type checking, tests, and build pass;
- AppGuardrail, Semgrep, Security Scan, and Commercial Readiness pass;
- CodeRabbit and human/security review feedback contain no unresolved actionable findings.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md",
"format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md",
"format": "prettier --single-quote --write ."
},
"devDependencies": {
Expand Down
Loading