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:
Bastian Wagner
2026-08-17 17:33:55 +02:00
parent 4aca6b64a0
commit 1d2467049d
58 changed files with 936 additions and 574 deletions

View File

@@ -0,0 +1,90 @@
import { AuthLoginController } from './auth-login.controller';
function fakeResponse() {
return {
redirect: jest.fn(),
cookie: jest.fn(),
clearCookie: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
}
describe('AuthLoginController', () => {
const environment = { appBaseUrl: 'http://localhost:4200' };
it('GET /auth/login redirects to the authorization URL built by AuthFlowService', async () => {
const authFlow = {
buildAuthorizationRedirect: jest
.fn()
.mockResolvedValue({ url: 'https://idp.example.test/oidc/auth?...' }),
};
const sessionStore = { deleteSession: jest.fn() };
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.login(res as never);
expect(res.redirect).toHaveBeenCalledWith(
'https://idp.example.test/oidc/auth?...',
);
});
it('GET /auth/callback sets an httpOnly session cookie and redirects into the app', async () => {
const authFlow = {
handleCallback: jest
.fn()
.mockResolvedValue({ sessionId: 'session-1', expiresIn: 3600 }),
};
const sessionStore = { deleteSession: jest.fn() };
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.callback('code-1', 'state-1', res as never);
expect(authFlow.handleCallback).toHaveBeenCalledWith('code-1', 'state-1');
expect(res.cookie).toHaveBeenCalledWith(
'travel_planner_session',
'session-1',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
maxAge: 3600 * 1000,
}),
);
expect(res.redirect).toHaveBeenCalledWith('http://localhost:4200/trips');
});
it('POST /auth/logout deletes the session and clears the cookie', async () => {
const authFlow = {};
const sessionStore = {
deleteSession: jest.fn().mockResolvedValue(undefined),
};
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.logout(
{ cookies: { travel_planner_session: 'session-1' } } as never,
res as never,
);
expect(sessionStore.deleteSession).toHaveBeenCalledWith('session-1');
expect(res.clearCookie).toHaveBeenCalledWith(
'travel_planner_session',
expect.objectContaining({ path: '/' }),
);
expect(res.status).toHaveBeenCalledWith(204);
});
});

View File

@@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpCode,
Inject,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { APP_ENVIRONMENT } from '../../../../libs/configuration/src';
import type { AppEnvironment } from '../../../../libs/configuration/src';
import {
AuthFlowService,
SessionStoreService,
SESSION_COOKIE_NAME,
} from '../../../../libs/auth/src';
@Controller('auth')
export class AuthLoginController {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly authFlow: AuthFlowService,
private readonly sessionStore: SessionStoreService,
) {}
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { url } = await this.authFlow.buildAuthorizationRedirect();
res.redirect(url);
}
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Res() res: Response,
): Promise<void> {
const { sessionId, expiresIn } = await this.authFlow.handleCallback(
code,
state,
);
res.cookie(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: this.environment.appBaseUrl.startsWith('https://'),
maxAge: expiresIn * 1000,
path: '/',
});
res.redirect(`${this.environment.appBaseUrl}/trips`);
}
@Post('logout')
@HttpCode(204)
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const sessionId = (req.cookies as Record<string, string> | undefined)?.[
SESSION_COOKIE_NAME
];
if (sessionId) await this.sessionStore.deleteSession(sessionId);
res.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
res.status(204).send();
}
}

View File

@@ -1,23 +0,0 @@
import { AuthSessionController } from './auth-session.controller';
describe('AuthSessionController', () => {
it('POST /auth/session exchanges the authorization code via the token exchange service', async () => {
const tokenExchange = {
exchangeAuthorizationCode: jest
.fn()
.mockResolvedValue({ accessToken: 'at-1', expiresIn: 3600 }),
};
const controller = new AuthSessionController(tokenExchange as never);
const dto = {
code: 'code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
};
await expect(controller.createSession(dto)).resolves.toEqual({
accessToken: 'at-1',
expiresIn: 3600,
});
expect(tokenExchange.exchangeAuthorizationCode).toHaveBeenCalledWith(dto);
});
});

View File

@@ -1,18 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TokenExchangeService } from '../../../../libs/auth/src';
import type {
AuthorizationCodeExchangeRequest,
AuthorizationCodeExchangeResult,
} from '../../../../libs/auth/src';
@Controller('auth')
export class AuthSessionController {
constructor(private readonly tokenExchange: TokenExchangeService) {}
@Post('session')
createSession(
@Body() dto: AuthorizationCodeExchangeRequest,
): Promise<AuthorizationCodeExchangeResult> {
return this.tokenExchange.exchangeAuthorizationCode(dto);
}
}

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthSessionController } from './auth-session.controller';
import { AuthLoginController } from './auth-login.controller';
@Module({
controllers: [AuthSessionController],
controllers: [AuthLoginController],
})
export class AuthApiModule {}

View File

@@ -1,11 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
const request = ctx
.switchToHttp()
.getRequest<{ user: AuthenticatedUser }>();
(_data: unknown, ctx: ExecutionContext): SessionUser => {
const request = ctx.switchToHttp().getRequest<{ user: SessionUser }>();
return request.user;
},
);

View File

@@ -1,10 +1,12 @@
import { RequestMethod } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ApiModule } from './api.module';
export async function bootstrapApi(): Promise<void> {
const app = await NestFactory.create(ApiModule);
app.enableShutdownHooks();
app.use(cookieParser());
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'health/live', method: RequestMethod.GET },

View File

@@ -1,8 +1,8 @@
import { TravelersController } from './travelers.controller';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TravelersController', () => {
const currentUser: AuthenticatedUser = {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',

View File

@@ -8,8 +8,8 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TravelersService,
TripMembershipGuard,
@@ -22,7 +22,7 @@ import type {
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('trips/:tripId/travelers')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TravelersController {
constructor(private readonly travelersService: TravelersService) {}
@@ -34,7 +34,7 @@ export class TravelersController {
@Post()
create(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTravelerDto,
): Promise<Traveler> {
return this.travelersService.createTraveler(tripId, dto, currentUser.id);

View File

@@ -1,8 +1,8 @@
import { TripInvitationsController } from './trip-invitations.controller';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripInvitationsController', () => {
const currentUser: AuthenticatedUser = {
const currentUser: SessionUser = {
id: 'owner-1',
externalSubjectId: 'sub-1',
displayName: 'Owner',

View File

@@ -7,8 +7,8 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripInvitationsService,
TripMembershipGuard,
@@ -26,11 +26,11 @@ export class TripInvitationsController {
constructor(private readonly invitationsService: TripInvitationsService) {}
@Post('trips/:tripId/invitations')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
create(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTripInvitationDto,
): Promise<{ invitation: TripInvitation; rawToken: string }> {
return this.invitationsService.createInvitation(
@@ -41,14 +41,14 @@ export class TripInvitationsController {
}
@Get('trips/:tripId/invitations')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
list(@Param('tripId') tripId: string): Promise<TripInvitation[]> {
return this.invitationsService.listInvitations(tripId);
}
@Delete('trips/:tripId/invitations/:invitationId')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
remove(
@Param('tripId') tripId: string,
@@ -58,10 +58,10 @@ export class TripInvitationsController {
}
@Post('invitations/:token/accept')
@UseGuards(OidcAuthGuard)
@UseGuards(SessionAuthGuard)
accept(
@Param('token') token: string,
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
): Promise<TripMember> {
return this.invitationsService.acceptInvitation(token, currentUser.id);
}

View File

@@ -7,7 +7,7 @@ import {
Patch,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import {
TripMembersService,
TripMembershipGuard,
@@ -25,7 +25,7 @@ interface UpdateTripMemberDto {
}
@Controller('trips/:tripId/members')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TripMembersController {
constructor(private readonly tripMembersService: TripMembersService) {}

View File

@@ -1,9 +1,9 @@
import { ForbiddenException } from '@nestjs/common';
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripPreferenceOverridesController', () => {
const member: AuthenticatedUser = {
const member: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',

View File

@@ -8,8 +8,8 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripMembershipGuard,
TripPreferenceOverridesService,
@@ -23,7 +23,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { CurrentTripRole } from './current-trip-role.decorator';
@Controller('trips/:tripId/preference-overrides')
@UseGuards(OidcAuthGuard, TripMembershipGuard)
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TripPreferenceOverridesController {
constructor(private readonly service: TripPreferenceOverridesService) {}
@@ -35,7 +35,7 @@ export class TripPreferenceOverridesController {
@Put()
async upsert(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
@CurrentTripRole() role: TripMemberRole,
@Body() dto: UpsertPreferenceOverrideDto,
): Promise<TripPreferenceOverride> {

View File

@@ -1,8 +1,8 @@
import { TripsController } from './trips.controller';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripsController', () => {
const currentUser: AuthenticatedUser = {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',

View File

@@ -9,8 +9,8 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripMembershipGuard,
TripRoles,
@@ -27,7 +27,7 @@ import type {
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('trips')
@UseGuards(OidcAuthGuard)
@UseGuards(SessionAuthGuard)
export class TripsController {
constructor(
private readonly tripsService: TripsService,
@@ -35,13 +35,13 @@ export class TripsController {
) {}
@Get()
list(@CurrentUser() currentUser: AuthenticatedUser): Promise<Trip[]> {
list(@CurrentUser() currentUser: SessionUser): Promise<Trip[]> {
return this.tripsService.listTripsForUser(currentUser.id);
}
@Post()
create(
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTripDto,
): Promise<Trip> {
return this.tripsService.createTrip(currentUser.id, dto);

View File

@@ -1,8 +1,8 @@
import { UsersController } from './users.controller';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
describe('UsersController', () => {
const currentUser: AuthenticatedUser = {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',

View File

@@ -6,8 +6,8 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { OidcAuthGuard } from '../../../../libs/auth/src';
import type { AuthenticatedUser } from '../../../../libs/auth/src';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
UsersService,
UserPreferencesService,
@@ -27,7 +27,7 @@ interface UserProfileResponse {
}
@Controller('users/me')
@UseGuards(OidcAuthGuard)
@UseGuards(SessionAuthGuard)
export class UsersController {
constructor(
private readonly usersService: UsersService,
@@ -36,7 +36,7 @@ export class UsersController {
@Get()
async me(
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
): Promise<UserProfileResponse> {
const user = await this.usersService.findById(currentUser.id);
if (!user) throw new NotFoundException('User not found');
@@ -51,14 +51,14 @@ export class UsersController {
@Get('preferences')
getPreferences(
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
): Promise<UserPreference> {
return this.preferencesService.getOrDefault(currentUser.id);
}
@Put('preferences')
updatePreferences(
@CurrentUser() currentUser: AuthenticatedUser,
@CurrentUser() currentUser: SessionUser,
@Body() dto: UpdateUserPreferenceDto,
): Promise<UserPreference> {
return this.preferencesService.upsert(currentUser.id, dto);