feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token exchange, access token in sessionStorage) with a classic backend-driven BFF: the browser only ever navigates to GET /api/v1/auth/login and is redirected straight to the IdP; PKCE verifier/state live server-side in Redis (SessionStoreService); GET /api/v1/auth/callback (now the registered IdP redirect URI, replacing the frontend's /auth/callback route, which is deleted) verifies the id_token, JIT-provisions the user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax cookie before redirecting into the app. No token material of any kind ever reaches the browser. OidcAuthGuard (per-request bearer JWT verification) is replaced by SessionAuthGuard (cookie -> Redis session lookup) across every controller that used it. cookie-parser is now wired into main.ts. Frontend AuthService shrinks to login()/logout()/ensureSessionChecked(); pkce.ts, auth.interceptor.ts, and the callback component/route are all removed as dead code under this model. New required env var: APP_BASE_URL (source of truth for the OIDC redirect_uri and the post-login redirect target). Verified end-to-end against the real API, Redis, and a mocked IdP: login redirect shape, callback cookie + redirect, state-replay rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
148
backend/libs/auth/src/auth-flow.service.spec.ts
Normal file
148
backend/libs/auth/src/auth-flow.service.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose';
|
||||
import { AuthFlowService } from './auth-flow.service';
|
||||
import type { AppEnvironment } from '../../configuration/src';
|
||||
|
||||
describe('AuthFlowService', () => {
|
||||
const environment: AppEnvironment = {
|
||||
databaseUrl: 'postgresql://u:p@postgres:5432/db',
|
||||
redisUrl: 'redis://redis:6379',
|
||||
oidcIssuer: 'https://idp.example.test',
|
||||
oidcAudience: 'client-1',
|
||||
oidcClientId: 'client-1',
|
||||
oidcClientSecret: 'secret-1',
|
||||
appBaseUrl: 'http://localhost:4200',
|
||||
appVersion: 'dev',
|
||||
teamCityBuildNumber: 'local',
|
||||
sourceRevision: 'local',
|
||||
};
|
||||
|
||||
function fakeDiscovery() {
|
||||
return {
|
||||
getAuthorizationEndpoint: jest
|
||||
.fn()
|
||||
.mockReturnValue('https://idp.example.test/oidc/auth'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAuthorizationRedirect', () => {
|
||||
it('persists a login attempt and returns a well-formed authorization URL', async () => {
|
||||
const sessionStore = { createLoginAttempt: jest.fn() };
|
||||
const service = new AuthFlowService(
|
||||
environment,
|
||||
fakeDiscovery() as never,
|
||||
{} as never,
|
||||
sessionStore as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const { url, state } = await service.buildAuthorizationRedirect();
|
||||
|
||||
expect(sessionStore.createLoginAttempt).toHaveBeenCalledWith(
|
||||
state,
|
||||
expect.any(String),
|
||||
);
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://idp.example.test/oidc/auth',
|
||||
);
|
||||
expect(parsed.searchParams.get('response_type')).toBe('code');
|
||||
expect(parsed.searchParams.get('client_id')).toBe('client-1');
|
||||
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
||||
'http://localhost:4200/api/v1/auth/callback',
|
||||
);
|
||||
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(parsed.searchParams.get('state')).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallback', () => {
|
||||
it('rejects when the state does not match a stored login attempt', async () => {
|
||||
const sessionStore = {
|
||||
consumeLoginAttempt: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new AuthFlowService(
|
||||
environment,
|
||||
fakeDiscovery() as never,
|
||||
{} as never,
|
||||
sessionStore as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.handleCallback('code-1', 'unknown-state'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('verifies the id_token, jit-provisions the user, and creates a session', async () => {
|
||||
const issuer = environment.oidcIssuer;
|
||||
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
||||
const jwk = (await exportJWK(publicKey)) as Record<string, string>;
|
||||
jwk.kid = 'flow-key';
|
||||
const jwks = createLocalJWKSet({ keys: [jwk as never] });
|
||||
|
||||
const idToken = await new SignJWT({
|
||||
sub: 'idp-sub-1',
|
||||
email: 'a@example.com',
|
||||
name: 'A',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'flow-key' })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(environment.oidcClientId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
|
||||
const sessionStore = {
|
||||
consumeLoginAttempt: jest.fn().mockResolvedValue('verifier-1'),
|
||||
createSession: jest.fn().mockResolvedValue('session-1'),
|
||||
};
|
||||
const tokenExchange = {
|
||||
exchangeAuthorizationCode: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: 'at-1', expiresIn: 3600, idToken }),
|
||||
};
|
||||
const usersService = {
|
||||
findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({
|
||||
id: 'local-1',
|
||||
externalSubjectId: 'idp-sub-1',
|
||||
displayName: 'A',
|
||||
email: 'a@example.com',
|
||||
}),
|
||||
};
|
||||
const discovery = {
|
||||
getAuthorizationEndpoint: jest.fn(),
|
||||
getVerificationKeySet: jest.fn().mockReturnValue(jwks),
|
||||
getIssuer: jest.fn().mockReturnValue(issuer),
|
||||
};
|
||||
|
||||
const service = new AuthFlowService(
|
||||
environment,
|
||||
discovery as never,
|
||||
tokenExchange as never,
|
||||
sessionStore as never,
|
||||
usersService as never,
|
||||
);
|
||||
|
||||
const result = await service.handleCallback('code-1', 'state-1');
|
||||
|
||||
expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith(
|
||||
'idp-sub-1',
|
||||
{
|
||||
email: 'a@example.com',
|
||||
displayName: 'A',
|
||||
},
|
||||
);
|
||||
expect(sessionStore.createSession).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'local-1',
|
||||
externalSubjectId: 'idp-sub-1',
|
||||
displayName: 'A',
|
||||
email: 'a@example.com',
|
||||
},
|
||||
3600,
|
||||
);
|
||||
expect(result).toEqual({ sessionId: 'session-1', expiresIn: 3600 });
|
||||
});
|
||||
});
|
||||
});
|
||||
106
backend/libs/auth/src/auth-flow.service.ts
Normal file
106
backend/libs/auth/src/auth-flow.service.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { APP_ENVIRONMENT } from '../../configuration/src';
|
||||
import type { AppEnvironment } from '../../configuration/src';
|
||||
import { UsersService } from '../../users/src';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
import { TokenExchangeService } from './token-exchange.service';
|
||||
import { SessionStoreService } from './session-store.service';
|
||||
import type { SessionUser } from './session-store.service';
|
||||
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||
|
||||
export interface AuthorizationRedirect {
|
||||
url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface CallbackResult {
|
||||
sessionId: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
const SCOPE = 'openid profile email';
|
||||
|
||||
@Injectable()
|
||||
export class AuthFlowService {
|
||||
constructor(
|
||||
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||
private readonly discovery: OidcDiscoveryService,
|
||||
private readonly tokenExchange: TokenExchangeService,
|
||||
private readonly sessionStore: SessionStoreService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
private getRedirectUri(): string {
|
||||
return `${this.environment.appBaseUrl}/api/v1/auth/callback`;
|
||||
}
|
||||
|
||||
async buildAuthorizationRedirect(): Promise<AuthorizationRedirect> {
|
||||
const codeVerifier = generateRandomString();
|
||||
const state = generateRandomString();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
|
||||
await this.sessionStore.createLoginAttempt(state, codeVerifier);
|
||||
|
||||
const url = new URL(this.discovery.getAuthorizationEndpoint());
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', this.environment.oidcClientId);
|
||||
url.searchParams.set('redirect_uri', this.getRedirectUri());
|
||||
url.searchParams.set('scope', SCOPE);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
return { url: url.toString(), state };
|
||||
}
|
||||
|
||||
async handleCallback(code: string, state: string): Promise<CallbackResult> {
|
||||
const codeVerifier = await this.sessionStore.consumeLoginAttempt(state);
|
||||
if (!codeVerifier) {
|
||||
throw new BadRequestException('Invalid or expired login attempt');
|
||||
}
|
||||
|
||||
const tokenResult = await this.tokenExchange.exchangeAuthorizationCode({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri: this.getRedirectUri(),
|
||||
});
|
||||
|
||||
const { payload } = await jwtVerify(
|
||||
tokenResult.idToken,
|
||||
this.discovery.getVerificationKeySet(),
|
||||
{
|
||||
issuer: this.discovery.getIssuer(),
|
||||
audience: this.environment.oidcClientId,
|
||||
},
|
||||
).catch(() => {
|
||||
throw new UnauthorizedException('Invalid ID token');
|
||||
});
|
||||
|
||||
const sub = payload.sub;
|
||||
if (!sub) throw new UnauthorizedException('ID token has no subject claim');
|
||||
|
||||
const user = await this.usersService.findOrCreateByExternalSubjectId(sub, {
|
||||
email: (payload.email as string) ?? '',
|
||||
displayName: (payload.name as string) ?? (payload.email as string) ?? sub,
|
||||
});
|
||||
|
||||
const sessionUser: SessionUser = {
|
||||
id: user.id,
|
||||
externalSubjectId: user.externalSubjectId,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
};
|
||||
const sessionId = await this.sessionStore.createSession(
|
||||
sessionUser,
|
||||
tokenResult.expiresIn,
|
||||
);
|
||||
|
||||
return { sessionId, expiresIn: tokenResult.expiresIn };
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisModule } from '../../infrastructure/src';
|
||||
import { UsersLibModule } from '../../users/src';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||
import { TokenExchangeService } from './token-exchange.service';
|
||||
import { SessionStoreService } from './session-store.service';
|
||||
import { SessionAuthGuard } from './session-auth.guard';
|
||||
import { AuthFlowService } from './auth-flow.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [UsersLibModule],
|
||||
providers: [OidcDiscoveryService, OidcAuthGuard, TokenExchangeService],
|
||||
imports: [RedisModule, UsersLibModule],
|
||||
providers: [
|
||||
OidcDiscoveryService,
|
||||
TokenExchangeService,
|
||||
SessionStoreService,
|
||||
SessionAuthGuard,
|
||||
AuthFlowService,
|
||||
],
|
||||
exports: [
|
||||
OidcDiscoveryService,
|
||||
OidcAuthGuard,
|
||||
TokenExchangeService,
|
||||
SessionStoreService,
|
||||
SessionAuthGuard,
|
||||
AuthFlowService,
|
||||
UsersLibModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './oidc-discovery.service';
|
||||
export * from './oidc-auth.guard';
|
||||
export * from './token-exchange.service';
|
||||
export * from './session-store.service';
|
||||
export * from './session-auth.guard';
|
||||
export * from './auth-flow.service';
|
||||
export * from './auth.module';
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose';
|
||||
import type { KeyLike } from 'jose';
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||
|
||||
const issuer = 'https://idp.example.test/';
|
||||
const audience = 'travel-planner-api';
|
||||
|
||||
function contextWithHeader(authorization?: string): ExecutionContext {
|
||||
const req: Record<string, unknown> = authorization
|
||||
? { headers: { authorization } }
|
||||
: { headers: {} };
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('OidcAuthGuard', () => {
|
||||
let privateKey: KeyLike;
|
||||
let discovery: {
|
||||
getVerificationKeySet: jest.Mock;
|
||||
getIssuer: jest.Mock;
|
||||
getAudience: jest.Mock;
|
||||
};
|
||||
let usersService: { findOrCreateByExternalSubjectId: jest.Mock };
|
||||
|
||||
beforeAll(async () => {
|
||||
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
|
||||
privateKey = pk;
|
||||
const jwk = await exportJWK(publicKey);
|
||||
(jwk as Record<string, string>).kid = 'test-key';
|
||||
const jwks = createLocalJWKSet({ keys: [jwk as never] });
|
||||
discovery = {
|
||||
getVerificationKeySet: jest.fn().mockReturnValue(jwks),
|
||||
getIssuer: jest.fn().mockReturnValue(issuer),
|
||||
getAudience: jest.fn().mockReturnValue(audience),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
usersService = {
|
||||
findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({
|
||||
id: 'local-1',
|
||||
externalSubjectId: 'idp-sub-1',
|
||||
displayName: 'A',
|
||||
email: 'a@example.com',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
async function sign(claims: Record<string, unknown>, expires = '5m') {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(audience)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expires)
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
it('rejects a request with no Authorization header', async () => {
|
||||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||
await expect(guard.canActivate(contextWithHeader())).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const token = await sign(
|
||||
{ sub: 'idp-sub-1', email: 'a@example.com', name: 'A' },
|
||||
'-10s',
|
||||
);
|
||||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||
await expect(
|
||||
guard.canActivate(contextWithHeader(`Bearer ${token}`)),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects a token issued for a different audience', async () => {
|
||||
const token = await new SignJWT({ sub: 'idp-sub-1' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuer(issuer)
|
||||
.setAudience('some-other-api')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||
await expect(
|
||||
guard.canActivate(contextWithHeader(`Bearer ${token}`)),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('provisions the local user and attaches req.user on a valid token', async () => {
|
||||
const token = await sign({
|
||||
sub: 'idp-sub-1',
|
||||
email: 'a@example.com',
|
||||
name: 'A',
|
||||
});
|
||||
const req: Record<string, unknown> = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
};
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith(
|
||||
'idp-sub-1',
|
||||
{
|
||||
email: 'a@example.com',
|
||||
displayName: 'A',
|
||||
},
|
||||
);
|
||||
expect(req.user).toEqual({
|
||||
id: 'local-1',
|
||||
externalSubjectId: 'idp-sub-1',
|
||||
displayName: 'A',
|
||||
email: 'a@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { jwtVerify } from 'jose';
|
||||
import type { JWTPayload } from 'jose';
|
||||
import { UsersService } from '../../users/src';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
externalSubjectId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly discovery: OidcDiscoveryService,
|
||||
private readonly users: UsersService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
headers: Record<string, string | undefined>;
|
||||
user?: AuthenticatedUser;
|
||||
}>();
|
||||
const header = request.headers?.authorization;
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||||
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
const result = await jwtVerify(
|
||||
token,
|
||||
this.discovery.getVerificationKeySet(),
|
||||
{
|
||||
issuer: this.discovery.getIssuer(),
|
||||
audience: this.discovery.getAudience(),
|
||||
},
|
||||
);
|
||||
payload = result.payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
|
||||
const sub = payload.sub;
|
||||
if (!sub) throw new UnauthorizedException('Token has no subject claim');
|
||||
|
||||
const user = await this.users.findOrCreateByExternalSubjectId(sub, {
|
||||
email: (payload.email as string) ?? '',
|
||||
displayName: (payload.name as string) ?? (payload.email as string) ?? sub,
|
||||
});
|
||||
|
||||
request.user = {
|
||||
id: user.id,
|
||||
externalSubjectId: user.externalSubjectId,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,20 @@ describe('OidcDiscoveryService', () => {
|
||||
oidcAudience: 'client-1',
|
||||
oidcClientId: 'client-1',
|
||||
oidcClientSecret: 'secret-1',
|
||||
appBaseUrl: 'http://localhost:4200',
|
||||
appVersion: 'dev',
|
||||
teamCityBuildNumber: 'local',
|
||||
sourceRevision: 'local',
|
||||
};
|
||||
|
||||
it('fetches the discovery document once and exposes the token endpoint', async () => {
|
||||
it('fetches the discovery document once and exposes its endpoints', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
jwks_uri: 'https://idp.example.test/oidc/jwks',
|
||||
token_endpoint: 'https://idp.example.test/oidc/token',
|
||||
authorization_endpoint: 'https://idp.example.test/oidc/auth',
|
||||
}),
|
||||
});
|
||||
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
||||
@@ -34,14 +36,20 @@ describe('OidcDiscoveryService', () => {
|
||||
expect(service.getTokenEndpoint()).toBe(
|
||||
'https://idp.example.test/oidc/token',
|
||||
);
|
||||
expect(service.getAuthorizationEndpoint()).toBe(
|
||||
'https://idp.example.test/oidc/auth',
|
||||
);
|
||||
expect(service.getIssuer()).toBe('https://idp.example.test');
|
||||
expect(service.getAudience()).toBe('client-1');
|
||||
});
|
||||
|
||||
it('throws when asked for the token endpoint before discovery has completed', () => {
|
||||
it('throws when asked for an endpoint before discovery has completed', () => {
|
||||
const service = new OidcDiscoveryService(environment);
|
||||
expect(() => service.getTokenEndpoint()).toThrow(
|
||||
'OIDC discovery has not completed yet',
|
||||
);
|
||||
expect(() => service.getAuthorizationEndpoint()).toThrow(
|
||||
'OIDC discovery has not completed yet',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { AppEnvironment } from '../../configuration/src';
|
||||
interface OidcDiscoveryDocument {
|
||||
jwks_uri: string;
|
||||
token_endpoint: string;
|
||||
authorization_endpoint: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcDiscoveryService implements OnModuleInit {
|
||||
private verificationKeySet: JWTVerifyGetKey | undefined;
|
||||
private tokenEndpoint: string | undefined;
|
||||
private authorizationEndpoint: string | undefined;
|
||||
|
||||
constructor(
|
||||
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||
@@ -29,6 +31,7 @@ export class OidcDiscoveryService implements OnModuleInit {
|
||||
const document = (await response.json()) as OidcDiscoveryDocument;
|
||||
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
|
||||
this.tokenEndpoint = document.token_endpoint;
|
||||
this.authorizationEndpoint = document.authorization_endpoint;
|
||||
}
|
||||
|
||||
getIssuer(): string {
|
||||
@@ -52,4 +55,11 @@ export class OidcDiscoveryService implements OnModuleInit {
|
||||
}
|
||||
return this.tokenEndpoint;
|
||||
}
|
||||
|
||||
getAuthorizationEndpoint(): string {
|
||||
if (!this.authorizationEndpoint) {
|
||||
throw new Error('OIDC discovery has not completed yet');
|
||||
}
|
||||
return this.authorizationEndpoint;
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/libs/auth/src/pkce.spec.ts
Normal file
20
backend/libs/auth/src/pkce.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||
|
||||
describe('pkce (backend)', () => {
|
||||
it('computes the RFC 7636 Appendix B S256 test vector', () => {
|
||||
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
expect(generateCodeChallenge(codeVerifier)).toBe(
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a URL-safe random string', () => {
|
||||
const value = generateRandomString();
|
||||
expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(value.length).toBeGreaterThanOrEqual(43);
|
||||
});
|
||||
|
||||
it('generates different values on each call', () => {
|
||||
expect(generateRandomString()).not.toBe(generateRandomString());
|
||||
});
|
||||
});
|
||||
9
backend/libs/auth/src/pkce.ts
Normal file
9
backend/libs/auth/src/pkce.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export function generateRandomString(byteLength = 32): string {
|
||||
return randomBytes(byteLength).toString('base64url');
|
||||
}
|
||||
|
||||
export function generateCodeChallenge(codeVerifier: string): string {
|
||||
return createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
56
backend/libs/auth/src/session-auth.guard.spec.ts
Normal file
56
backend/libs/auth/src/session-auth.guard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { SessionAuthGuard } from './session-auth.guard';
|
||||
|
||||
describe('SessionAuthGuard', () => {
|
||||
function contextWithCookies(
|
||||
cookies?: Record<string, string>,
|
||||
): ExecutionContext {
|
||||
const req: Record<string, unknown> = { cookies };
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
it('rejects a request with no session cookie', async () => {
|
||||
const sessionStore = { getSession: jest.fn() };
|
||||
const guard = new SessionAuthGuard(sessionStore as never);
|
||||
|
||||
await expect(guard.canActivate(contextWithCookies())).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(sessionStore.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a session cookie that does not match a stored session', async () => {
|
||||
const sessionStore = { getSession: jest.fn().mockResolvedValue(undefined) };
|
||||
const guard = new SessionAuthGuard(sessionStore as never);
|
||||
|
||||
await expect(
|
||||
guard.canActivate(
|
||||
contextWithCookies({ travel_planner_session: 'unknown-session' }),
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('attaches the stored session user to the request on a valid session', async () => {
|
||||
const user = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
email: 'a@example.com',
|
||||
};
|
||||
const sessionStore = { getSession: jest.fn().mockResolvedValue(user) };
|
||||
const guard = new SessionAuthGuard(sessionStore as never);
|
||||
|
||||
const req: Record<string, unknown> = {
|
||||
cookies: { travel_planner_session: 'session-1' },
|
||||
};
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
expect(sessionStore.getSession).toHaveBeenCalledWith('session-1');
|
||||
expect(req.user).toEqual(user);
|
||||
});
|
||||
});
|
||||
33
backend/libs/auth/src/session-auth.guard.ts
Normal file
33
backend/libs/auth/src/session-auth.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
SessionStoreService,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from './session-store.service';
|
||||
import type { SessionUser } from './session-store.service';
|
||||
|
||||
interface RequestWithSession {
|
||||
cookies?: Record<string, string>;
|
||||
user?: SessionUser;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionAuthGuard implements CanActivate {
|
||||
constructor(private readonly sessionStore: SessionStoreService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
const sessionId = request.cookies?.[SESSION_COOKIE_NAME];
|
||||
if (!sessionId) throw new UnauthorizedException('Missing session cookie');
|
||||
|
||||
const user = await this.sessionStore.getSession(sessionId);
|
||||
if (!user) throw new UnauthorizedException('Session expired or invalid');
|
||||
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
76
backend/libs/auth/src/session-store.service.spec.ts
Normal file
76
backend/libs/auth/src/session-store.service.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { SessionStoreService } from './session-store.service';
|
||||
|
||||
function fakeRedis() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
store,
|
||||
set: jest.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
||||
del: jest.fn((key: string) => {
|
||||
const existed = store.delete(key);
|
||||
return Promise.resolve(existed ? 1 : 0);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionStoreService', () => {
|
||||
describe('login attempts', () => {
|
||||
it('stores and consumes a code verifier for a given state exactly once', async () => {
|
||||
const redis = fakeRedis();
|
||||
const service = new SessionStoreService(redis as never);
|
||||
|
||||
await service.createLoginAttempt('state-1', 'verifier-1');
|
||||
|
||||
await expect(service.consumeLoginAttempt('state-1')).resolves.toBe(
|
||||
'verifier-1',
|
||||
);
|
||||
await expect(
|
||||
service.consumeLoginAttempt('state-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown state', async () => {
|
||||
const redis = fakeRedis();
|
||||
const service = new SessionStoreService(redis as never);
|
||||
|
||||
await expect(
|
||||
service.consumeLoginAttempt('never-seen'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
const user = {
|
||||
id: 'u1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Alex',
|
||||
email: 'a@example.com',
|
||||
};
|
||||
|
||||
it('creates a session and retrieves the stored user by session id', async () => {
|
||||
const redis = fakeRedis();
|
||||
const service = new SessionStoreService(redis as never);
|
||||
|
||||
const sessionId = await service.createSession(user, 3600);
|
||||
expect(sessionId).toEqual(expect.any(String));
|
||||
|
||||
await expect(service.getSession(sessionId)).resolves.toEqual(user);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown or deleted session', async () => {
|
||||
const redis = fakeRedis();
|
||||
const service = new SessionStoreService(redis as never);
|
||||
|
||||
const sessionId = await service.createSession(user, 3600);
|
||||
await service.deleteSession(sessionId);
|
||||
|
||||
await expect(service.getSession(sessionId)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.getSession('does-not-exist'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
backend/libs/auth/src/session-store.service.ts
Normal file
64
backend/libs/auth/src/session-store.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Redis from 'ioredis';
|
||||
import { REDIS_CLIENT } from '../../infrastructure/src';
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
externalSubjectId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'travel_planner_session';
|
||||
|
||||
const LOGIN_ATTEMPT_TTL_SECONDS = 10 * 60;
|
||||
|
||||
function loginAttemptKey(state: string): string {
|
||||
return `oidc:login-attempt:${state}`;
|
||||
}
|
||||
|
||||
function sessionKey(sessionId: string): string {
|
||||
return `session:${sessionId}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionStoreService {
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
||||
|
||||
async createLoginAttempt(state: string, codeVerifier: string): Promise<void> {
|
||||
await this.redis.set(
|
||||
loginAttemptKey(state),
|
||||
codeVerifier,
|
||||
'EX',
|
||||
LOGIN_ATTEMPT_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
async consumeLoginAttempt(state: string): Promise<string | undefined> {
|
||||
const key = loginAttemptKey(state);
|
||||
const codeVerifier = await this.redis.get(key);
|
||||
if (codeVerifier) await this.redis.del(key);
|
||||
return codeVerifier ?? undefined;
|
||||
}
|
||||
|
||||
async createSession(user: SessionUser, ttlSeconds: number): Promise<string> {
|
||||
const sessionId = randomBytes(32).toString('base64url');
|
||||
await this.redis.set(
|
||||
sessionKey(sessionId),
|
||||
JSON.stringify(user),
|
||||
'EX',
|
||||
ttlSeconds,
|
||||
);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async getSession(sessionId: string): Promise<SessionUser | undefined> {
|
||||
const raw = await this.redis.get(sessionKey(sessionId));
|
||||
return raw ? (JSON.parse(raw) as SessionUser) : undefined;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
await this.redis.del(sessionKey(sessionId));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
oidcAudience: 'client-1',
|
||||
oidcClientId: 'client-1',
|
||||
oidcClientSecret: 'super-secret',
|
||||
appBaseUrl: 'http://localhost:4200',
|
||||
appVersion: 'dev',
|
||||
teamCityBuildNumber: 'local',
|
||||
sourceRevision: 'local',
|
||||
@@ -21,7 +22,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
|
||||
}
|
||||
|
||||
it('posts a client-secret-authenticated request and returns only the safe fields', async () => {
|
||||
it('posts a client-secret-authenticated request and returns the access + id token', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -41,10 +42,14 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
const result = await service.exchangeAuthorizationCode({
|
||||
code: 'auth-code-1',
|
||||
codeVerifier: 'verifier-1',
|
||||
redirectUri: 'http://localhost:4200/auth/callback',
|
||||
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ accessToken: 'at-1', expiresIn: 3600 });
|
||||
expect(result).toEqual({
|
||||
accessToken: 'at-1',
|
||||
expiresIn: 3600,
|
||||
idToken: 'idt-1',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://idp.example.test/oidc/token');
|
||||
@@ -55,11 +60,11 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
expect(body.get('code')).toBe('auth-code-1');
|
||||
expect(body.get('code_verifier')).toBe('verifier-1');
|
||||
expect(body.get('redirect_uri')).toBe(
|
||||
'http://localhost:4200/auth/callback',
|
||||
'http://localhost:4200/api/v1/auth/callback',
|
||||
);
|
||||
});
|
||||
|
||||
it('never leaks the refresh_token or id_token to the caller', async () => {
|
||||
it('never exposes the refresh_token, which is not needed by this MVP (no silent refresh)', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -79,11 +84,10 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
const result = await service.exchangeAuthorizationCode({
|
||||
code: 'auth-code-1',
|
||||
codeVerifier: 'verifier-1',
|
||||
redirectUri: 'http://localhost:4200/auth/callback',
|
||||
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
||||
});
|
||||
|
||||
expect(result).not.toHaveProperty('refreshToken');
|
||||
expect(result).not.toHaveProperty('idToken');
|
||||
});
|
||||
|
||||
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
|
||||
@@ -103,7 +107,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||
service.exchangeAuthorizationCode({
|
||||
code: 'bad-code',
|
||||
codeVerifier: 'verifier-1',
|
||||
redirectUri: 'http://localhost:4200/auth/callback',
|
||||
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AuthorizationCodeExchangeRequest {
|
||||
export interface AuthorizationCodeExchangeResult {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
idToken: string;
|
||||
}
|
||||
|
||||
interface TokenEndpointResponse {
|
||||
@@ -63,6 +64,7 @@ export class TokenExchangeService {
|
||||
return {
|
||||
accessToken: tokenResponse.access_token,
|
||||
expiresIn: tokenResponse.expires_in,
|
||||
idToken: tokenResponse.id_token ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user