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.
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { TokenExchangeService } from './token-exchange.service';
|
|
import type { AppEnvironment } from '../../configuration/src';
|
|
|
|
describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
|
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: 'super-secret',
|
|
appBaseUrl: 'http://localhost:4200',
|
|
appVersion: 'dev',
|
|
teamCityBuildNumber: 'local',
|
|
sourceRevision: 'local',
|
|
};
|
|
|
|
function fakeDiscovery(
|
|
tokenEndpoint = 'https://idp.example.test/oidc/token',
|
|
) {
|
|
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
|
|
}
|
|
|
|
it('posts a client-secret-authenticated request and returns the access + id token', async () => {
|
|
const fetchMock = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
access_token: 'at-1',
|
|
expires_in: 3600,
|
|
refresh_token: 'rt-1',
|
|
id_token: 'idt-1',
|
|
}),
|
|
});
|
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
|
|
|
const service = new TokenExchangeService(
|
|
environment,
|
|
fakeDiscovery() as never,
|
|
);
|
|
const result = await service.exchangeAuthorizationCode({
|
|
code: 'auth-code-1',
|
|
codeVerifier: 'verifier-1',
|
|
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
|
});
|
|
|
|
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');
|
|
const body = new URLSearchParams(init.body as string);
|
|
expect(body.get('grant_type')).toBe('authorization_code');
|
|
expect(body.get('client_id')).toBe('client-1');
|
|
expect(body.get('client_secret')).toBe('super-secret');
|
|
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/api/v1/auth/callback',
|
|
);
|
|
});
|
|
|
|
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: () =>
|
|
Promise.resolve({
|
|
access_token: 'at-1',
|
|
expires_in: 3600,
|
|
refresh_token: 'rt-1',
|
|
id_token: 'idt-1',
|
|
}),
|
|
});
|
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
|
|
|
const service = new TokenExchangeService(
|
|
environment,
|
|
fakeDiscovery() as never,
|
|
);
|
|
const result = await service.exchangeAuthorizationCode({
|
|
code: 'auth-code-1',
|
|
codeVerifier: 'verifier-1',
|
|
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
|
});
|
|
|
|
expect(result).not.toHaveProperty('refreshToken');
|
|
});
|
|
|
|
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
|
|
const fetchMock = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: () => Promise.resolve({ error: 'invalid_grant' }),
|
|
});
|
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
|
|
|
const service = new TokenExchangeService(
|
|
environment,
|
|
fakeDiscovery() as never,
|
|
);
|
|
|
|
await expect(
|
|
service.exchangeAuthorizationCode({
|
|
code: 'bad-code',
|
|
codeVerifier: 'verifier-1',
|
|
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
|
|
}),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
});
|