-
Notifications
You must be signed in to change notification settings - Fork 0
test(identity): verify OAuth browser sessions over HTTP #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d0036c6
test(identity): exercise OAuth browser session flow over HTTP
seonghobae 9113149
docs(identity): plan OAuth HTTP integration coverage
seonghobae 97601d0
chore(ci): format OAuth HTTP integration artifacts
seonghobae b407864
style(identity): format OAuth HTTP integration test
seonghobae 37ea13d
chore(ci): diagnose OAuth integration formatting
seonghobae 09865d2
chore(ci): print OAuth integration formatting diff
seonghobae 438cc3b
style(identity): apply canonical OAuth integration formatting
seonghobae 6512057
chore(ci): restore validation workflow
seonghobae 3e09537
chore(ci): remove temporary formatting diagnostic
seonghobae 6f848c2
refactor(identity): use standard Nest module decorator
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
292 changes: 292 additions & 0 deletions
292
apps/identity-service/src/oauth-http.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }), | ||
| ]); | ||
| }); | ||
| }); | ||
18 changes: 18 additions & 0 deletions
18
docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.