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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user