Compare commits

...

10 Commits

Author SHA1 Message Date
Bastian Wagner
1d2467049d 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.
2026-08-17 17:33:55 +02:00
Bastian Wagner
4aca6b64a0 style: prettier formatting 2026-08-17 16:43:15 +02:00
Bastian Wagner
698a2180aa chore: auto-load .env for local backend start/migrate scripts 2026-08-17 16:42:57 +02:00
Bastian Wagner
981cecbcbd feat: switch oidc client to confidential (backend token exchange)
The provisioned IdP client (https://auth.forgecore.work) is confidential
rather than public/PKCE-only, so a client secret must never reach the
browser. The frontend now only performs the Authorization Code + PKCE
redirect itself (hand-rolled PKCE, oidc-client-ts dependency removed)
and hands the resulting code + verifier to a new, intentionally
unauthenticated POST /api/v1/auth/session endpoint, which performs the
code-for-tokens exchange server-side using OIDC_CLIENT_SECRET and
returns only {accessToken, expiresIn} — refresh_token/id_token are
never forwarded to the client.

New required backend env vars: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET.
Added frontend/proxy.conf.json so the Angular dev server forwards
/api and /health to the local API without needing CORS.
2026-08-17 16:36:49 +02:00
Bastian Wagner
8eb5f0a3ed docs: record phase 02 completion 2026-08-17 16:10:36 +02:00
Bastian Wagner
854df5bb2d fix: export guard dependencies so cross-module @UseGuards resolves
NestJS resolves a guard referenced via @UseGuards(SomeGuard) using the
consuming module's injector, not the guard's own declaring module's
injector. OidcAuthGuard and TripMembershipGuard are shared across
several feature modules, so every constructor dependency they need
(UsersRepository/UsersService, TripMembersRepository, etc.) must be
re-exported by AuthModule/TripsLibModule/UsersLibModule, not just the
guard classes themselves. Found via the Phase 02 end-to-end smoke test
against a mocked IdP, which failed to boot the API before this fix.
2026-08-17 16:10:27 +02:00
Bastian Wagner
60e09d5050 chore: wire oidc environment configuration 2026-08-17 15:54:30 +02:00
Bastian Wagner
6e991bfd00 feat: add minimal trips list, create trip, and members ui 2026-08-17 15:49:32 +02:00
Bastian Wagner
bd91a3da4a feat: add oidc authorization code with pkce login flow 2026-08-17 15:43:17 +02:00
Bastian Wagner
dedb3fff40 feat: add trip preference overrides with precedence resolution 2026-08-17 15:38:03 +02:00
76 changed files with 2121 additions and 246 deletions

View File

@@ -13,3 +13,14 @@ TEAMCITY_BUILD_NUMBER=local
SOURCE_REVISION=local
TLS_CERT_FILE=/etc/travel-planner/tls/tls.crt
TLS_KEY_FILE=/etc/travel-planner/tls/tls.key
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
OIDC_AUDIENCE=travel-planner-api
OIDC_CLIENT_ID=travel-planner-web
# OIDC_CLIENT_SECRET: this client is confidential (holds a secret). Never commit
# a real value here; supply it only via the deployment host's secret store /
# the developer's own shell environment.
OIDC_CLIENT_SECRET=change-me-outside-source-control
# APP_BASE_URL: the public origin end users load the app from (used to build the
# OIDC redirect_uri and the post-login redirect target). Must match a redirect
# URI registered with the IdP client, e.g. https://travel-planner.example.com
APP_BASE_URL=http://localhost:4200

View File

@@ -19,12 +19,35 @@ docs/ Specs, plans, and architecture documentation
```bash
pnpm install
pnpm dev:infra
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend start:api
pnpm --filter frontend start
```
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
Create a **`.env` file at the repository root** (already gitignored, never committed) with the required values. `backend`'s `start:api`/`start:worker`/`migrate` scripts load it automatically via Node's `--env-file-if-exists` — no manual `export` needed. **`OIDC_CLIENT_SECRET` is a real secret — put it only in this local, gitignored file, never in `.env.example`, never pasted into a shared/logged terminal.** `OIDC_AUDIENCE` should be set to the same value as `OIDC_CLIENT_ID` unless your IdP issues a distinct API audience.
```dotenv
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
REDIS_URL=redis://localhost:6379
OIDC_ISSUER=https://auth.forgecore.work
OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_AUDIENCE=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_CLIENT_SECRET=<your-client-secret>
APP_BASE_URL=http://localhost:4200
```
Run pending migrations against the dev database (first time only), then start the API and frontend:
```bash
pnpm --filter backend build:api
pnpm --filter backend migrate
pnpm --filter backend start:api
pnpm --filter frontend start
```
Open `http://localhost:4200`. The Angular dev server proxies `/api/*` and `/health/*` to the API on `localhost:3000` (see `frontend/proxy.conf.json`), so no CORS configuration is needed locally. Make sure the IdP client `client_a297fd8d9c1f47a79d3600ea0c96984` allows the redirect URI `http://localhost:4200/api/v1/auth/callback` — note this is a **backend** URL (proxied through the same origin), not the frontend's `/auth/callback`.
This IdP client is **confidential** (it has a client secret), so the entire Authorization Code + PKCE flow — including the callback and token exchange — runs server-side (see "OIDC client type" below). The browser only ever sees an httpOnly session cookie, never an access token.
## Quality gates
```bash
@@ -59,3 +82,33 @@ Production Docker Compose (`compose.yml`) publishes **exactly one** host port, o
- `GET /api/v1/version` — safe build metadata only (`appVersion`, `teamCityBuildNumber`, `sourceRevision`); never database/Redis URLs.
- The compiled no-op migration entry point (`backend/dist/apps/api/src/migration.js`) gives `deploy.sh` a stable container command contract; Phase 02 replaces its body with the real versioned migration runner.
- Verified end-to-end: `docker compose -f compose.yml up` with a real TLS certificate serves `/health/live`, `/health/ready`, `/`, and `/api/v1/version` through the single published edge port, with `postgres`/`redis`/`api`/`worker` unreachable from the host.
## Phase 02 status: OIDC auth, users, and trip core complete
- Authentication: OIDC Authorization Code + PKCE against an external IdP. No local password storage; users are keyed by the OIDC `sub` claim and just-in-time provisioned on first login.
- **OIDC client type & session model:** this deployment's IdP client is confidential (has a client secret), not a public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the **entire** Authorization Code + PKCE flow runs server-side, not just the token exchange:
- `GET /api/v1/auth/login` (`AuthLoginController`) generates the PKCE verifier/challenge and `state`, stores the verifier in Redis keyed by `state` (`SessionStoreService`, short TTL), and 302-redirects the browser straight to the IdP's `authorization_endpoint`. The frontend only ever navigates to this URL (`AuthService.login()`); it holds no PKCE state at all.
- The IdP redirects back to `GET /api/v1/auth/callback` (a **backend** URL, registered as the client's redirect URI) with `code`+`state`. The backend consumes the matching verifier from Redis (single-use — replaying a `state` returns 400), exchanges the code using `OIDC_CLIENT_SECRET` (`TokenExchangeService`), verifies the returned `id_token`'s signature via the IdP's JWKS, JIT-provisions the local `User` from its claims, and stores a server-side session in Redis (`SessionStoreService`, TTL = access-token lifetime).
- The callback sets **one** cookie — `travel_planner_session` (httpOnly, `SameSite=Lax`, `Secure` when `APP_BASE_URL` is https) — containing only an opaque session id, then redirects the browser into the app (`${APP_BASE_URL}/trips`). The browser never receives an access token, ID token, or refresh token; `refresh_token` is never even stored.
- Every subsequent request to a protected route is authenticated by `SessionAuthGuard`, which reads the cookie and looks up the session in Redis — no per-request JWT verification, no `Authorization` header, and (since the frontend and API share an origin via the edge/dev-proxy) no CORS configuration needed.
- `POST /api/v1/auth/logout` deletes the Redis session and clears the cookie. The frontend's `AuthService.ensureSessionChecked()` simply calls `GET /api/v1/users/me` on demand to ask "is there a valid session?" — it holds no token/session state of its own beyond a boolean signal.
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `APP_BASE_URL` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`; `OIDC_CLIENT_SECRET` is a real secret, never committed). `APP_BASE_URL` is the public origin used to build the OIDC `redirect_uri` and the post-login redirect target — it must match a redirect URI registered with the IdP client. New frontend build-time values: `OIDC_ISSUER`, `OIDC_CLIENT_ID` (non-secret; baked into the production bundle by `docker/edge.Dockerfile`, never read from the container at runtime — used only to know where to send the user, since the actual flow is backend-driven).
- Real, versioned database migrations (`node-pg-migrate`, files under `backend/migrations/`) replace the Phase 01 no-op `migration.ts` body; the container command contract (`node backend/dist/apps/api/src/migration.js`) is unchanged. Database access goes through `kysely` (a type-safe query builder, not an ORM) over the existing `pg.Pool`; there is no schema auto-sync anywhere.
- New routes: `GET /api/v1/auth/login`, `GET /api/v1/auth/callback`, `POST /api/v1/auth/logout`, `GET/PUT /api/v1/users/me`(`/preferences`), `GET/POST /api/v1/trips`, `GET/PATCH/DELETE /api/v1/trips/:tripId`, `GET/PUT /api/v1/trips/:tripId/settings`, `GET/PATCH/DELETE /api/v1/trips/:tripId/members(/:memberId)`, `POST/GET/DELETE /api/v1/trips/:tripId/invitations(/:invitationId)`, `POST /api/v1/invitations/:token/accept`, `GET/POST/PATCH/DELETE /api/v1/trips/:tripId/travelers(/:travelerId)`, `GET/PUT/DELETE /api/v1/trips/:tripId/preference-overrides(/:overrideId)`.
- Authorization is enforced backend-side by `SessionAuthGuard` (who) and `TripMembershipGuard` (trip access + `@TripRoles('OWNER')`), never only in the frontend. `TripMember` and `Traveler` are independent tables — a `Traveler` never implies or requires trip membership.
- `Trip.version` optimistic locking: a stale `PATCH` (mismatched `version`) returns HTTP 409 and never silently overwrites; proven by both a mocked unit test and a real-database integration test.
- Run backend integration tests (migration idempotency + optimistic-locking conflict) against `compose.dev.yml`:
```bash
pnpm dev:infra
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
REDIS_URL=redis://localhost:6379 \
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner \
OIDC_AUDIENCE=travel-planner-api \
OIDC_CLIENT_ID=test-client \
OIDC_CLIENT_SECRET=test-secret \
APP_BASE_URL=http://localhost:4200 \
pnpm --filter backend test:integration
```
- Verified end-to-end against the real running API, Redis, and a mocked IdP (local JWKS + discovery document): `/auth/login` redirects with a well-formed PKCE authorization URL, `/auth/callback` verifies the ID token, JIT-provisions the user, sets the httpOnly session cookie, and redirects into the app; replaying a consumed `state` is rejected (400); `/users/me` is 401 without the cookie and 200 with it; `/auth/logout` clears the session so `/users/me` returns 401 again. Trip flows verified: create/read, first `PATCH` with the correct version succeeds, a second `PATCH` reusing the stale version → 409, and a user with no `trip_members` row for the trip → 403.

View File

@@ -6,12 +6,14 @@ import { HealthModule } from './health/health.module';
import { VersionModule } from './version/version.module';
import { UsersApiModule } from './users/users.module';
import { TripsApiModule } from './trips/trips.module';
import { AuthApiModule } from './auth/auth.module';
@Module({
imports: [
ConfigurationModule,
HealthModule,
VersionModule,
AuthApiModule,
UsersApiModule,
TripsApiModule,
],

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

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthLoginController } from './auth-login.controller';
@Module({
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

@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { TripMemberRole } from '../../../../libs/trips/src';
export const CurrentTripRole = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): TripMemberRole => {
const request = ctx
.switchToHttp()
.getRequest<{ tripMembership: { role: TripMemberRole } }>();
return request.tripMembership.role;
},
);

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

@@ -0,0 +1,88 @@
import { ForbiddenException } from '@nestjs/common';
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripPreferenceOverridesController', () => {
const member: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
it('GET /trips/:tripId/preference-overrides lists overrides', async () => {
const service = { listOverrides: jest.fn().mockResolvedValue([]) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(controller.list('t1')).resolves.toEqual([]);
expect(service.listOverrides).toHaveBeenCalledWith('t1');
});
it('allows a member to upsert their own USER override', async () => {
const dto = {
subject: { type: 'USER' as const, userId: 'u1' },
overrides: { preferredPace: 'fast' },
};
const updated = { id: 'o1', tripId: 't1', ...dto };
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).resolves.toEqual(updated);
expect(service.upsertOverride).toHaveBeenCalledWith(
't1',
dto.subject,
dto.overrides,
);
});
it('denies a non-owner member from setting another user override', async () => {
const dto = {
subject: { type: 'USER' as const, userId: 'someone-else' },
overrides: {},
};
const service = { upsertOverride: jest.fn() };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).rejects.toThrow(ForbiddenException);
expect(service.upsertOverride).not.toHaveBeenCalled();
});
it('denies a non-owner member from setting a traveler override', async () => {
const dto = {
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
overrides: {},
};
const service = { upsertOverride: jest.fn() };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).rejects.toThrow(ForbiddenException);
});
it('allows an OWNER to set overrides for any subject', async () => {
const dto = {
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
overrides: {},
};
const updated = { id: 'o1', tripId: 't1', ...dto };
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'OWNER', dto),
).resolves.toEqual(updated);
});
it('DELETE /trips/:tripId/preference-overrides/:overrideId removes an override', async () => {
const service = { removeOverride: jest.fn().mockResolvedValue(undefined) };
const controller = new TripPreferenceOverridesController(service as never);
await controller.remove('t1', 'o1');
expect(service.removeOverride).toHaveBeenCalledWith('t1', 'o1');
});
});

View File

@@ -0,0 +1,59 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Put,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripMembershipGuard,
TripPreferenceOverridesService,
} from '../../../../libs/trips/src';
import type {
TripMemberRole,
TripPreferenceOverride,
UpsertPreferenceOverrideDto,
} from '../../../../libs/trips/src';
import { CurrentUser } from '../auth/current-user.decorator';
import { CurrentTripRole } from './current-trip-role.decorator';
@Controller('trips/:tripId/preference-overrides')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TripPreferenceOverridesController {
constructor(private readonly service: TripPreferenceOverridesService) {}
@Get()
list(@Param('tripId') tripId: string): Promise<TripPreferenceOverride[]> {
return this.service.listOverrides(tripId);
}
@Put()
async upsert(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: SessionUser,
@CurrentTripRole() role: TripMemberRole,
@Body() dto: UpsertPreferenceOverrideDto,
): Promise<TripPreferenceOverride> {
const isOwnUserOverride =
dto.subject.type === 'USER' && dto.subject.userId === currentUser.id;
if (role !== 'OWNER' && !isOwnUserOverride) {
throw new ForbiddenException(
'Only the trip owner may set preference overrides for other members or travelers',
);
}
return this.service.upsertOverride(tripId, dto.subject, dto.overrides);
}
@Delete(':overrideId')
remove(
@Param('tripId') tripId: string,
@Param('overrideId') overrideId: string,
): Promise<void> {
return this.service.removeOverride(tripId, overrideId);
}
}

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

@@ -5,6 +5,7 @@ import { TripsController } from './trips.controller';
import { TripMembersController } from './trip-members.controller';
import { TripInvitationsController } from './trip-invitations.controller';
import { TravelersController } from './travelers.controller';
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
@Module({
imports: [AuthModule, TripsLibModule],
@@ -13,6 +14,7 @@ import { TravelersController } from './travelers.controller';
TripMembersController,
TripInvitationsController,
TravelersController,
TripPreferenceOverridesController,
],
})
export class TripsApiModule {}

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);

View 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 });
});
});
});

View 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 };
}
}

View File

@@ -1,11 +1,29 @@
import { Module } from '@nestjs/common';
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],
exports: [OidcDiscoveryService, OidcAuthGuard],
imports: [RedisModule, UsersLibModule],
providers: [
OidcDiscoveryService,
TokenExchangeService,
SessionStoreService,
SessionAuthGuard,
AuthFlowService,
],
exports: [
OidcDiscoveryService,
TokenExchangeService,
SessionStoreService,
SessionAuthGuard,
AuthFlowService,
UsersLibModule,
],
})
export class AuthModule {}

View File

@@ -1,3 +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';

View File

@@ -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',
});
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,55 @@
import { OidcDiscoveryService } from './oidc-discovery.service';
import type { AppEnvironment } from '../../configuration/src';
describe('OidcDiscoveryService', () => {
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',
};
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;
const service = new OidcDiscoveryService(environment);
await service.onModuleInit();
expect(fetchMock).toHaveBeenCalledWith(
'https://idp.example.test/.well-known/openid-configuration',
);
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 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',
);
});
});

View File

@@ -6,11 +6,15 @@ 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,
@@ -26,6 +30,8 @@ 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 {
@@ -42,4 +48,18 @@ export class OidcDiscoveryService implements OnModuleInit {
}
return this.verificationKeySet;
}
getTokenEndpoint(): string {
if (!this.tokenEndpoint) {
throw new Error('OIDC discovery has not completed yet');
}
return this.tokenEndpoint;
}
getAuthorizationEndpoint(): string {
if (!this.authorizationEndpoint) {
throw new Error('OIDC discovery has not completed yet');
}
return this.authorizationEndpoint;
}
}

View 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());
});
});

View 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');
}

View 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);
});
});

View 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;
}
}

View 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();
});
});
});

View 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));
}
}

View File

@@ -0,0 +1,114 @@
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);
});
});

View File

@@ -0,0 +1,70 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APP_ENVIRONMENT } from '../../configuration/src';
import type { AppEnvironment } from '../../configuration/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
export interface AuthorizationCodeExchangeRequest {
code: string;
codeVerifier: string;
redirectUri: string;
}
export interface AuthorizationCodeExchangeResult {
accessToken: string;
expiresIn: number;
idToken: string;
}
interface TokenEndpointResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
}
/**
* Exchanges an OIDC authorization code for tokens on behalf of the (confidential,
* client-secret-bearing) SPA client. The secret never reaches the browser: the
* frontend performs the Authorization Code + PKCE redirect itself, then hands the
* resulting code + PKCE verifier to this service, which is the only place the
* client secret is used.
*/
@Injectable()
export class TokenExchangeService {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly discovery: OidcDiscoveryService,
) {}
async exchangeAuthorizationCode(
request: AuthorizationCodeExchangeRequest,
): Promise<AuthorizationCodeExchangeResult> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.environment.oidcClientId,
client_secret: this.environment.oidcClientSecret,
code: request.code,
code_verifier: request.codeVerifier,
redirect_uri: request.redirectUri,
});
const response = await fetch(this.discovery.getTokenEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
throw new BadRequestException(
'The identity provider rejected the authorization code',
);
}
const tokenResponse = (await response.json()) as TokenEndpointResponse;
return {
accessToken: tokenResponse.access_token,
expiresIn: tokenResponse.expires_in,
idToken: tokenResponse.id_token ?? '',
};
}
}

View File

@@ -12,6 +12,9 @@ describe('loadEnvironment', () => {
REDIS_URL: 'redis://redis:6379',
OIDC_ISSUER: 'https://idp.example.test/',
OIDC_AUDIENCE: 'travel-planner-api',
OIDC_CLIENT_ID: 'test-client',
OIDC_CLIENT_SECRET: 'test-secret',
APP_BASE_URL: 'http://localhost:4200/',
APP_VERSION: '1.2.3',
TEAMCITY_BUILD_NUMBER: '42',
SOURCE_REVISION: 'abc123',
@@ -21,6 +24,9 @@ describe('loadEnvironment', () => {
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test/',
oidcAudience: 'travel-planner-api',
oidcClientId: 'test-client',
oidcClientSecret: 'test-secret',
appBaseUrl: 'http://localhost:4200',
appVersion: '1.2.3',
teamCityBuildNumber: '42',
sourceRevision: 'abc123',

View File

@@ -3,6 +3,9 @@ export interface AppEnvironment {
redisUrl: string;
oidcIssuer: string;
oidcAudience: string;
oidcClientId: string;
oidcClientSecret: string;
appBaseUrl: string;
appVersion: string;
teamCityBuildNumber: string;
sourceRevision: string;
@@ -20,6 +23,9 @@ export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
redisUrl: required(env, 'REDIS_URL'),
oidcIssuer: required(env, 'OIDC_ISSUER'),
oidcAudience: required(env, 'OIDC_AUDIENCE'),
oidcClientId: required(env, 'OIDC_CLIENT_ID'),
oidcClientSecret: required(env, 'OIDC_CLIENT_SECRET'),
appBaseUrl: required(env, 'APP_BASE_URL').replace(/\/$/, ''),
appVersion: env.APP_VERSION?.trim() || 'dev',
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',

View File

@@ -5,5 +5,7 @@ export * from './trip-members.service';
export * from './trip-membership.guard';
export * from './trip-invitations.service';
export * from './travelers.service';
export * from './trip-preference-overrides.service';
export * from './preference-precedence';
export * from './trip-roles.decorator';
export * from './trips.module';

View File

@@ -0,0 +1,47 @@
import { resolveEffectivePreference } from './preference-precedence';
import type { UserPreferenceFields } from '../../users/src';
describe('resolveEffectivePreference', () => {
const appDefault: UserPreferenceFields = {
preferredPace: 'moderate',
preferredBudgetLevel: 'medium',
maxWalkingDistanceKm: 5,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};
it('falls back to the application default when nothing is set', () => {
expect(
resolveEffectivePreference(undefined, undefined, appDefault),
).toEqual(appDefault);
});
it('prefers the persistent user preference over the application default', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
expect(
resolveEffectivePreference(undefined, userPref, appDefault).preferredPace,
).toBe('relaxed');
});
it('prefers a trip-specific override over the persistent user preference', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
const override = { preferredPace: 'fast' };
expect(
resolveEffectivePreference(override, userPref, appDefault).preferredPace,
).toBe('fast');
});
it('merges field-by-field rather than replacing the whole object', () => {
const userPref = {
...appDefault,
preferredPace: 'relaxed',
childFriendlyPreferred: true,
};
const override = { preferredPace: 'fast' };
const result = resolveEffectivePreference(override, userPref, appDefault);
expect(result.preferredPace).toBe('fast');
expect(result.childFriendlyPreferred).toBe(true);
});
});

View File

@@ -0,0 +1,14 @@
import type { UserPreferenceFields } from '../../users/src';
/**
* Precedence: trip override > persistent user preference > application default.
* Merges field-by-field so an override touching only one field never masks the
* caller's other persisted preferences.
*/
export function resolveEffectivePreference(
override: Partial<UserPreferenceFields> | undefined,
userPreference: UserPreferenceFields | undefined,
appDefault: UserPreferenceFields,
): UserPreferenceFields {
return { ...appDefault, ...userPreference, ...override };
}

View File

@@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type { PreferenceSubject, TripPreferenceOverride } from './trip.types';
function toOverride(row: {
id: string;
trip_id: string;
user_id: string | null;
traveler_id: string | null;
overrides: unknown;
created_at: Date;
updated_at: Date;
}): TripPreferenceOverride {
return {
id: row.id,
tripId: row.trip_id,
userId: row.user_id,
travelerId: row.traveler_id,
overrides: (row.overrides ?? {}) as Record<string, unknown>,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripPreferenceOverridesRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async listByTrip(tripId: string): Promise<TripPreferenceOverride[]> {
const rows = await this.db
.selectFrom('trip_preference_overrides')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toOverride);
}
async upsert(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
const userId = subject.type === 'USER' ? subject.userId : null;
const travelerId = subject.type === 'TRAVELER' ? subject.travelerId : null;
const row = await this.db
.insertInto('trip_preference_overrides')
.values({
trip_id: tripId,
user_id: userId,
traveler_id: travelerId,
overrides: overrides,
})
.onConflict((oc) =>
(subject.type === 'USER'
? oc.columns(['trip_id', 'user_id']).where('user_id', 'is not', null)
: oc
.columns(['trip_id', 'traveler_id'])
.where('traveler_id', 'is not', null)
).doUpdateSet({
overrides: overrides,
updated_at: new Date().toISOString(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
return toOverride(row);
}
async remove(tripId: string, overrideId: string): Promise<void> {
await this.db
.deleteFrom('trip_preference_overrides')
.where('trip_id', '=', tripId)
.where('id', '=', overrideId)
.execute();
}
}

View File

@@ -0,0 +1,23 @@
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
describe('TripPreferenceOverridesService', () => {
it('forwards the subject and overrides to the repository', async () => {
const created = {
id: 'o1',
tripId: 't1',
userId: 'u1',
travelerId: null,
overrides: { preferredPace: 'fast' },
};
const repository = { upsert: jest.fn().mockResolvedValue(created) };
const service = new TripPreferenceOverridesService(repository as never);
const subject = { type: 'USER' as const, userId: 'u1' };
await expect(
service.upsertOverride('t1', subject, { preferredPace: 'fast' }),
).resolves.toEqual(created);
expect(repository.upsert).toHaveBeenCalledWith('t1', subject, {
preferredPace: 'fast',
});
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import type { PreferenceSubject, TripPreferenceOverride } from './trip.types';
@Injectable()
export class TripPreferenceOverridesService {
constructor(private readonly repository: TripPreferenceOverridesRepository) {}
listOverrides(tripId: string): Promise<TripPreferenceOverride[]> {
return this.repository.listByTrip(tripId);
}
upsertOverride(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
return this.repository.upsert(tripId, subject, overrides);
}
removeOverride(tripId: string, overrideId: string): Promise<void> {
return this.repository.remove(tripId, overrideId);
}
}

View File

@@ -122,6 +122,24 @@ export interface UpdateTravelerDto {
linkedUserId?: string | null;
}
export type PreferenceSubject =
{ type: 'USER'; userId: string } | { type: 'TRAVELER'; travelerId: string };
export interface TripPreferenceOverride {
id: string;
tripId: string;
userId: string | null;
travelerId: string | null;
overrides: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface UpsertPreferenceOverrideDto {
subject: PreferenceSubject;
overrides: Record<string, unknown>;
}
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
webResearchEnabled: false,
periodicAgentReviewEnabled: false,

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/src';
import { TripsRepository } from './trips.repository';
import { TripsService } from './trips.service';
@@ -11,7 +11,10 @@ import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripInvitationsService } from './trip-invitations.service';
import { TravelersRepository } from './travelers.repository';
import { TravelersService } from './travelers.service';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
@Global()
@Module({
imports: [DatabaseModule],
providers: [
@@ -26,14 +29,23 @@ import { TravelersService } from './travelers.service';
TripInvitationsService,
TravelersRepository,
TravelersService,
TripPreferenceOverridesRepository,
TripPreferenceOverridesService,
],
exports: [
TripsRepository,
TripsService,
TripSettingsRepository,
TripSettingsService,
TripMembersRepository,
TripMembersService,
TripMembershipGuard,
TripInvitationsRepository,
TripInvitationsService,
TravelersRepository,
TravelersService,
TripPreferenceOverridesRepository,
TripPreferenceOverridesService,
],
})
export class TripsLibModule {}

View File

@@ -13,6 +13,11 @@ import { UserPreferencesService } from './user-preferences.service';
UserPreferencesRepository,
UserPreferencesService,
],
exports: [UsersService, UserPreferencesService],
exports: [
UsersRepository,
UsersService,
UserPreferencesRepository,
UserPreferencesService,
],
})
export class UsersLibModule {}

View File

@@ -13,8 +13,9 @@
"start": "nest start api",
"start:dev": "nest start api --watch",
"start:debug": "nest start api --debug --watch",
"start:api": "node dist/apps/api/src/main.js",
"start:worker": "node dist/apps/worker/src/main.js",
"start:api": "node --env-file-if-exists=../.env dist/apps/api/src/main.js",
"start:worker": "node --env-file-if-exists=../.env dist/apps/worker/src/main.js",
"migrate": "node --env-file-if-exists=../.env dist/apps/api/src/migration.js",
"lint": "eslint \"{apps,libs}/**/*.ts\" --fix",
"test": "jest --runInBand",
"test:watch": "jest --watch",
@@ -27,6 +28,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"cookie-parser": "^1.4.7",
"ioredis": "^6.0.0",
"jose": "^5.10.0",
"kysely": "0.28.17",
@@ -41,6 +43,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",

View File

@@ -2,3 +2,6 @@ process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test';
process.env.REDIS_URL ??= 'redis://localhost:6379';
process.env.OIDC_ISSUER ??= 'https://idp.example.test/';
process.env.OIDC_AUDIENCE ??= 'travel-planner-api';
process.env.OIDC_CLIENT_ID ??= 'test-client';
process.env.OIDC_CLIENT_SECRET ??= 'test-secret';
process.env.APP_BASE_URL ??= 'http://localhost:4200';

View File

@@ -4,6 +4,9 @@ services:
build:
context: .
dockerfile: docker/edge.Dockerfile
args:
OIDC_ISSUER: "${OIDC_ISSUER}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
ports:
- "${APP_HTTPS_PORT:-443}:443"
volumes:
@@ -25,6 +28,11 @@ services:
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
REDIS_URL: "redis://redis:6379"
OIDC_ISSUER: "${OIDC_ISSUER}"
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
APP_BASE_URL: "${APP_BASE_URL}"
APP_VERSION: "${APP_VERSION:-dev}"
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
@@ -50,6 +58,11 @@ services:
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
REDIS_URL: "redis://redis:6379"
OIDC_ISSUER: "${OIDC_ISSUER}"
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
APP_BASE_URL: "${APP_BASE_URL}"
APP_VERSION: "${APP_VERSION:-dev}"
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
SOURCE_REVISION: "${SOURCE_REVISION:-local}"

View File

@@ -6,6 +6,14 @@ COPY frontend/package.json frontend/package.json
COPY backend/package.json backend/package.json
RUN pnpm install --frozen-lockfile
COPY frontend frontend
ARG OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
ARG OIDC_CLIENT_ID=travel-planner-web
RUN sed -i \
-e "s#issuer: '.*',#issuer: '${OIDC_ISSUER}',#" \
-e "s#clientId: '.*',#clientId: '${OIDC_CLIENT_ID}',#" \
frontend/src/environments/environment.production.ts
RUN pnpm --filter frontend build
FROM nginx:1.29.8-alpine

View File

@@ -30,3 +30,11 @@ TeamCity supplies immutable `IMAGE_TAG` values (see `scripts/teamcity/build-imag
| Rollback | `scripts/teamcity/rollback.sh` |
The existing TeamCity project configures these as command-line/SSH build steps; all deployment logic stays in version control, not in TeamCity step configuration.
## OIDC configuration (Phase 02+)
`api` and `worker` both require `OIDC_ISSUER` and `OIDC_AUDIENCE` at startup (validated fail-fast by `loadEnvironment`, same as `DATABASE_URL`/`REDIS_URL`). Neither is a secret — this is a public PKCE client with no client secret. The Angular production bundle bakes `OIDC_ISSUER`/`OIDC_CLIENT_ID` in at **image build time** via `docker/edge.Dockerfile` build args (sourced from the `OIDC_ISSUER`/`OIDC_CLIENT_ID` environment variables passed to `docker compose build`), not at container runtime, since static frontend assets cannot read server-side environment variables after the fact.
## Migrations (Phase 02+)
`backend/apps/api/src/migration.ts` now runs real, versioned `node-pg-migrate` migrations from `backend/migrations/`; the Phase 01 no-op body has been replaced. The container command contract (`node backend/dist/apps/api/src/migration.js`) is unchanged, so `scripts/teamcity/deploy.sh` required no changes. Migrations are copied into the `travel-api` image so `docker compose run --rm --no-deps api node backend/dist/apps/api/src/migration.js` has everything it needs.

View File

@@ -1325,3 +1325,40 @@ Do not start any of the following during this phase; each belongs to a later, ex
- Payment/booking automation (permanent non-goal).
Phase 03 begins only after the Phase 02 acceptance checklist is reviewed and green.
---
## Addendum: Confidential IdP Client Requires Backend Token Exchange (2026-08-17)
The plan's Task 10 assumed a public, PKCE-only SPA client and used `oidc-client-ts` to perform the full Authorization Code + PKCE token exchange directly in the browser. During manual end-to-end testing against the actual target IdP (`https://auth.forgecore.work`), it turned out the provisioned client is **confidential** (it has a client secret) rather than public. A client secret must never be shipped in a browser bundle, so this required a real, user-confirmed architecture change rather than a workaround:
- The frontend still performs the Authorization Code + PKCE redirect itself (hand-rolled PKCE generation in `frontend/src/app/auth/pkce.ts`; `oidc-client-ts` was removed as a dependency since its callback handling assumes a direct-to-IdP token exchange that doesn't fit this model).
- The resulting authorization `code` and PKCE `code_verifier` are POSTed to a new, intentionally unauthenticated backend endpoint, `POST /api/v1/auth/session` (`AuthSessionController``TokenExchangeService`), which performs the code-for-tokens exchange using `OIDC_CLIENT_SECRET` server-side and returns only `{ accessToken, expiresIn }``refresh_token`/`id_token` are deliberately never forwarded to the frontend.
- New required backend env vars: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (in addition to the already-planned `OIDC_ISSUER`/`OIDC_AUDIENCE`).
- `OIDC_AUDIENCE` is operationally set equal to `OIDC_CLIENT_ID` for this IdP, since it does not issue a separate API-resource audience; no code change was needed for this, only a configuration convention.
- `OidcDiscoveryService` now also captures `token_endpoint` (in addition to `jwks_uri`), needed for the backend-side token exchange.
- A local dev proxy (`frontend/proxy.conf.json`, wired into `angular.json`'s `serve` target) forwards `/api/*` and `/health/*` from the Angular dev server to the API, avoiding a need for CORS configuration in local development (production already avoids CORS entirely since `edge` serves both origins).
This is a deployment-specific IdP constraint, not a general Travel Planner requirement — a future public-client IdP could skip the backend proxy — but the BFF pattern is what ships today since it is what the actual configured IdP requires.
---
## Addendum 2: Full Backend-Driven Session Flow, Not Just the Token Exchange (2026-08-17)
Shortly after Addendum 1 shipped, a review question ("why does OIDC discovery run in the frontend at all — shouldn't everything run in the backend?") led to a further, user-confirmed architecture change. Addendum 1 still had the frontend fetch IdP discovery itself, generate its own PKCE verifier/state, store the access token in `sessionStorage`, and POST the code+verifier to a `POST /api/v1/auth/session` endpoint. That is a workable "hybrid" BFF, but it (a) duplicated IdP knowledge between frontend and backend, and (b) still put the raw access token in JS-reachable browser storage, which is unnecessary exposure to XSS given the backend already brokers everything else.
The flow shipped instead is a classic, fully backend-driven BFF:
- `GET /api/v1/auth/login` (`AuthLoginController``AuthFlowService.buildAuthorizationRedirect`) generates the PKCE verifier/challenge and `state` **server-side**, persists `state → codeVerifier` in Redis (`SessionStoreService.createLoginAttempt`, 10-minute TTL), and issues an HTTP 302 straight to the IdP's `authorization_endpoint`. The frontend's `AuthService.login()` is now a one-line `window.location.href` navigation; it holds no PKCE state and never calls the IdP directly.
- The registered redirect URI is now the **backend's** `${APP_BASE_URL}/api/v1/auth/callback`, not a frontend route. `frontend/src/app/auth/callback/` (the Angular callback component) was deleted entirely — there is nothing for the frontend to do on callback, since the backend redirects straight into `/trips` once the session is established. A new required env var, `APP_BASE_URL`, is the single source of truth for both the redirect URI sent to the IdP and the post-login redirect target.
- `GET /api/v1/auth/callback` (`AuthFlowService.handleCallback`) consumes the one-time `state` (a replay returns 400), performs the token exchange (`TokenExchangeService`, unchanged from Addendum 1), verifies the response `id_token`'s signature/issuer/audience via `jose` against the IdP's JWKS, JIT-provisions the `User` from its claims, and creates a Redis-backed session (`SessionStoreService.createSession`, TTL = access-token lifetime) holding `{id, externalSubjectId, displayName, email}`. `TokenExchangeService.exchangeAuthorizationCode` now also returns `idToken` (previously deliberately omitted) since it's needed here — the guarantee that tokens never reach the browser is now structural (the callback response is a redirect with a Set-Cookie header, never a JSON body), not just a return-type convention.
- The response sets exactly one cookie, `travel_planner_session` (httpOnly, `SameSite=Lax`, `Secure` when `APP_BASE_URL` is `https://`), containing only an opaque session id — never a JWT or any token material.
- `OidcAuthGuard` (bearer-JWT verification per request) was deleted and replaced by `SessionAuthGuard`, which reads the cookie and looks up the session in Redis. Every controller that referenced `OidcAuthGuard`/`AuthenticatedUser` was updated to `SessionAuthGuard`/`SessionUser` (mechanical rename, same guard-composition pattern with `TripMembershipGuard`).
- `POST /api/v1/auth/logout` deletes the Redis session and clears the cookie.
- `main.ts` now registers Express's `cookie-parser` middleware globally (new dependency), since `SessionAuthGuard` reads `req.cookies`.
- Frontend `pkce.ts`, `auth.interceptor.ts` (Bearer-header attachment — no longer needed since cookies are attached automatically by the browser for same-origin requests), and the `oidc-client-ts`-free-but-still-manual `POST /api/v1/auth/session` call are all gone. `AuthService` is now ~40 lines: `login()` navigates, `logout()` POSTs and clears local state, `ensureSessionChecked()` asks `GET /api/v1/users/me` and caches the in-flight promise so route guards don't trigger duplicate checks.
- `provideHttpClient(withInterceptors([authInterceptor]))` reverted to plain `provideHttpClient()`.
Net effect: the browser holds zero token material at any point — not in `sessionStorage`, not in a JS-readable cookie, not in memory beyond the lifetime of the login redirect itself. This closes the XSS-exfiltration surface that Addendum 1's `sessionStorage`-held access token still had, at the cost of session state now living in Redis (already a hard dependency of this app) and one more required env var (`APP_BASE_URL`).
Verified end-to-end with the same style of mocked-IdP smoke test used in Task 13, extended to cover: login redirect shape (PKCE params present, targets the IdP), callback setting the httpOnly cookie and redirecting to `${APP_BASE_URL}/trips`, `state` replay rejection (400), `/users/me` 401→200 transition around the cookie, and logout returning `/users/me` to 401.

View File

@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
@@ -46,7 +47,13 @@
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
"serviceWorker": "ngsw-config.json",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
},
"development": {
"optimization": false,
@@ -58,6 +65,9 @@
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "frontend:build:production"

4
frontend/proxy.conf.json Normal file
View File

@@ -0,0 +1,4 @@
{
"/api": { "target": "http://localhost:3000", "secure": false },
"/health": { "target": "http://localhost:3000", "secure": false }
}

View File

@@ -1,5 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { provideServiceWorker } from '@angular/service-worker';
@@ -8,6 +9,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',

View File

@@ -1,5 +1,11 @@
<main class="app-shell">
<h1>Travel Planner</h1>
<p>Reisen planen, gemeinsam entscheiden.</p>
<nav>
<a routerLink="/trips">Reisen</a>
@if (authService.isAuthenticated()) {
<button type="button" (click)="logout()">Abmelden</button>
}
</nav>
<router-outlet />
</main>

View File

@@ -1,3 +1,15 @@
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [];
export const routes: Routes = [
{
path: 'trips',
canActivate: [authGuard],
loadComponent: () => import('./trips/trips-list/trips-list').then((m) => m.TripsList),
},
{
path: 'trips/:tripId',
canActivate: [authGuard],
loadComponent: () => import('./trips/trip-detail/trip-detail').then((m) => m.TripDetail),
},
];

View File

@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});

View File

@@ -1,10 +1,17 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, RouterLink],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {}
export class App {
protected readonly authService = inject(AuthService);
logout(): void {
void this.authService.logout();
}
}

View File

@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('authGuard', () => {
it('allows activation when a session already exists', async () => {
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(true), login: vi.fn() };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(true);
expect(authService.login).not.toHaveBeenCalled();
});
it('triggers login and denies activation when no session exists', async () => {
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(false), login: vi.fn() };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(false);
expect(authService.login).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,13 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = async () => {
const authService = inject(AuthService);
const authenticated = await authService.ensureSessionChecked();
if (authenticated) {
return true;
}
authService.login();
return false;
};

View File

@@ -0,0 +1,62 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
describe('AuthService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('starts with an unknown authentication state until checked', () => {
const service = TestBed.inject(AuthService);
expect(service.isAuthenticated()).toBeUndefined();
});
it('login() navigates to the backend login endpoint', () => {
vi.stubGlobal('location', { ...window.location, href: '' });
const service = TestBed.inject(AuthService);
service.login();
expect(window.location.href).toBe('/api/v1/auth/login');
});
it('ensureSessionChecked() reports authenticated when /users/me succeeds, and only fetches once', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const service = TestBed.inject(AuthService);
await expect(service.ensureSessionChecked()).resolves.toBe(true);
await expect(service.ensureSessionChecked()).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/v1/users/me');
expect(service.isAuthenticated()).toBe(true);
});
it('ensureSessionChecked() reports unauthenticated when /users/me returns 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const service = TestBed.inject(AuthService);
await expect(service.ensureSessionChecked()).resolves.toBe(false);
expect(service.isAuthenticated()).toBe(false);
});
it('ensureSessionChecked() reports unauthenticated when the request itself fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const service = TestBed.inject(AuthService);
await expect(service.ensureSessionChecked()).resolves.toBe(false);
});
it('logout() posts to the backend and clears the authenticated state', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const service = TestBed.inject(AuthService);
await service.logout();
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', { method: 'POST' });
expect(service.isAuthenticated()).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '../../environments/environment';
/**
* The IdP client backing this app is confidential (holds a client secret), so
* the entire Authorization Code + PKCE dance — including the PKCE verifier and
* the resulting access token — is handled server-side (see
* `GET /api/v1/auth/login`, `GET /api/v1/auth/callback`). The backend sets an
* httpOnly session cookie; the browser never sees an access token at all.
* This service therefore only triggers navigation and asks the backend
* "is there a valid session?" — it holds no tokens or PKCE state itself.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
readonly isAuthenticated = signal<boolean | undefined>(undefined);
private sessionCheck: Promise<boolean> | undefined;
login(): void {
window.location.href = `${environment.apiBaseUrl}/auth/login`;
}
async logout(): Promise<void> {
await fetch(`${environment.apiBaseUrl}/auth/logout`, { method: 'POST' });
this.sessionCheck = undefined;
this.isAuthenticated.set(false);
}
ensureSessionChecked(): Promise<boolean> {
if (!this.sessionCheck) {
this.sessionCheck = this.checkSession();
}
return this.sessionCheck;
}
private async checkSession(): Promise<boolean> {
try {
const response = await fetch(`${environment.apiBaseUrl}/users/me`);
this.isAuthenticated.set(response.ok);
return response.ok;
} catch {
this.isAuthenticated.set(false);
return false;
}
}
}

View File

@@ -0,0 +1,16 @@
<section class="trip-detail">
@if (trip(); as trip) {
<h2>{{ trip.name }}</h2>
}
@if (conflictError()) {
<p class="error">Die Reise wurde zwischenzeitlich von jemand anderem geändert. Bitte neu laden.</p>
}
<h3>Mitglieder</h3>
<ul>
@for (member of members(); track member.id) {
<li>{{ member.userId }} ({{ member.role }})</li>
}
</ul>
</section>

View File

@@ -0,0 +1,60 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { TripDetail } from './trip-detail';
import { TripsApiService } from '../trips-api.service';
describe('TripDetail', () => {
it('renders the trip name and members for the routed tripId', async () => {
const tripsApi = {
getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
listMembers: vi.fn().mockReturnValue(of([{ id: 'm1', userId: 'u1', role: 'OWNER', status: 'ACTIVE' }])),
updateTrip: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [TripDetail],
providers: [
{ provide: TripsApiService, useValue: tripsApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(TripDetail);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(tripsApi.getTrip).toHaveBeenCalledWith('t1');
expect(tripsApi.listMembers).toHaveBeenCalledWith('t1');
expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
});
it('shows a plain error message on a 409 optimistic-locking conflict', async () => {
const tripsApi = {
getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
listMembers: vi.fn().mockReturnValue(of([])),
updateTrip: vi.fn().mockReturnValue({
subscribe: (observer: { error: (err: unknown) => void }) => observer.error({ status: 409 }),
}),
};
await TestBed.configureTestingModule({
imports: [TripDetail],
providers: [
{ provide: TripsApiService, useValue: tripsApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(TripDetail);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
component.rename('New name');
expect(component.conflictError()).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { Trip, TripMember, TripsApiService } from '../trips-api.service';
@Component({
selector: 'app-trip-detail',
standalone: true,
imports: [],
templateUrl: './trip-detail.html',
})
export class TripDetail implements OnInit {
private readonly tripsApi = inject(TripsApiService);
private readonly route = inject(ActivatedRoute);
private tripId!: string;
readonly trip = signal<Trip | undefined>(undefined);
readonly members = signal<TripMember[]>([]);
readonly conflictError = signal(false);
ngOnInit(): void {
this.tripId = this.route.snapshot.paramMap.get('tripId') as string;
this.tripsApi.getTrip(this.tripId).subscribe((trip) => this.trip.set(trip));
this.tripsApi.listMembers(this.tripId).subscribe((members) => this.members.set(members));
}
rename(name: string): void {
const current = this.trip();
if (!current) return;
this.conflictError.set(false);
this.tripsApi.updateTrip(this.tripId, { name, version: current.version }).subscribe({
next: (updated) => this.trip.set(updated),
error: (error: HttpErrorResponse) => {
if (error.status === 409) {
this.conflictError.set(true);
}
},
});
}
}

View File

@@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { describe, expect, it, afterEach } from 'vitest';
import { TripsApiService } from './trips-api.service';
describe('TripsApiService', () => {
let service: TripsApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(TripsApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('lists trips from /api/v1/trips', () => {
let result: unknown;
service.listTrips().subscribe((trips) => (result = trips));
const req = httpMock.expectOne('/api/v1/trips');
expect(req.request.method).toBe('GET');
req.flush([{ id: 't1', name: 'Slovenia 2027' }]);
expect(result).toEqual([{ id: 't1', name: 'Slovenia 2027' }]);
});
it('creates a trip via POST /api/v1/trips', () => {
service.createTrip({ name: 'Slovenia 2027' }).subscribe();
const req = httpMock.expectOne('/api/v1/trips');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ name: 'Slovenia 2027' });
req.flush({ id: 't1', name: 'Slovenia 2027' });
});
it('gets a single trip via GET /api/v1/trips/:id', () => {
service.getTrip('t1').subscribe();
const req = httpMock.expectOne('/api/v1/trips/t1');
expect(req.request.method).toBe('GET');
req.flush({ id: 't1' });
});
it('lists members via GET /api/v1/trips/:id/members', () => {
service.listMembers('t1').subscribe();
const req = httpMock.expectOne('/api/v1/trips/t1/members');
expect(req.request.method).toBe('GET');
req.flush([]);
});
});

View File

@@ -0,0 +1,69 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type TripStatus = 'DRAFT' | 'PLANNING' | 'BOOKING' | 'UPCOMING' | 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
export interface Trip {
id: string;
name: string;
description: string | null;
ownerId: string;
startDate: string | null;
endDate: string | null;
status: TripStatus;
planningStage: string | null;
currency: string;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CreateTripDto {
name: string;
description?: string;
startDate?: string;
endDate?: string;
currency?: string;
}
export interface UpdateTripDto {
name?: string;
description?: string | null;
version: number;
}
export interface TripMember {
id: string;
tripId: string;
userId: string;
role: 'OWNER' | 'MEMBER';
status: 'INVITED' | 'ACTIVE' | 'DECLINED';
}
@Injectable({ providedIn: 'root' })
export class TripsApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/trips`;
listTrips(): Observable<Trip[]> {
return this.http.get<Trip[]>(this.baseUrl);
}
createTrip(dto: CreateTripDto): Observable<Trip> {
return this.http.post<Trip>(this.baseUrl, dto);
}
getTrip(tripId: string): Observable<Trip> {
return this.http.get<Trip>(`${this.baseUrl}/${tripId}`);
}
updateTrip(tripId: string, dto: UpdateTripDto): Observable<Trip> {
return this.http.patch<Trip>(`${this.baseUrl}/${tripId}`, dto);
}
listMembers(tripId: string): Observable<TripMember[]> {
return this.http.get<TripMember[]>(`${this.baseUrl}/${tripId}/members`);
}
}

View File

@@ -0,0 +1,14 @@
<section class="trips-list">
<h2>Reisen</h2>
<ul>
@for (trip of trips(); track trip.id) {
<li><a [routerLink]="['/trips', trip.id]">{{ trip.name }}</a></li>
}
</ul>
<form (ngSubmit)="createTrip()">
<input type="text" [ngModel]="newTripName()" (ngModelChange)="newTripName.set($event)" name="tripName" placeholder="Neue Reise" />
<button type="submit">Reise erstellen</button>
</form>
</section>

View File

@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { TripsList } from './trips-list';
import { TripsApiService } from '../trips-api.service';
describe('TripsList', () => {
it('renders trip names returned by TripsApiService', async () => {
const tripsApi = {
listTrips: vi.fn().mockReturnValue(of([{ id: 't1', name: 'Slovenia 2027' }])),
createTrip: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [TripsList],
providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(TripsList);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
});
it('calls createTrip when the create form is submitted', async () => {
const tripsApi = {
listTrips: vi.fn().mockReturnValue(of([])),
createTrip: vi.fn().mockReturnValue(of({ id: 't2', name: 'New Trip' })),
};
await TestBed.configureTestingModule({
imports: [TripsList],
providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(TripsList);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
component.newTripName.set('New Trip');
component.createTrip();
expect(tripsApi.createTrip).toHaveBeenCalledWith({ name: 'New Trip' });
});
});

View File

@@ -0,0 +1,30 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Trip, TripsApiService } from '../trips-api.service';
@Component({
selector: 'app-trips-list',
standalone: true,
imports: [RouterLink, FormsModule],
templateUrl: './trips-list.html',
})
export class TripsList implements OnInit {
private readonly tripsApi = inject(TripsApiService);
readonly trips = signal<Trip[]>([]);
readonly newTripName = signal('');
ngOnInit(): void {
this.tripsApi.listTrips().subscribe((trips) => this.trips.set(trips));
}
createTrip(): void {
const name = this.newTripName().trim();
if (!name) return;
this.tripsApi.createTrip({ name }).subscribe((trip) => {
this.trips.update((trips) => [...trips, trip]);
this.newTripName.set('');
});
}
}

View File

@@ -0,0 +1,13 @@
// Values here are placeholders. `docker/edge.Dockerfile` overwrites this file at
// image build time from the OIDC_ISSUER/OIDC_CLIENT_ID build args (see Task 12),
// so no secret ever needs to be baked into source control.
export const environment = {
production: true,
apiBaseUrl: '/api/v1',
oidc: {
issuer: 'https://idp.example.invalid/realms/travel-planner',
clientId: 'travel-planner-web',
redirectUri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
},
};

View File

@@ -0,0 +1,10 @@
export const environment = {
production: false,
apiBaseUrl: '/api/v1',
oidc: {
issuer: 'https://auth.forgecore.work',
clientId: 'client_a297fd8d9c1f47a79d3600ea0c96984',
redirectUri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
},
};

29
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.1
version: 11.2.1(@nestjs/common@11.2.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
ioredis:
specifier: ^6.0.0
version: 6.0.0
@@ -60,6 +63,9 @@ importers:
'@nestjs/testing':
specifier: ^11.0.1
version: 11.2.1(@nestjs/common@11.2.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(@nestjs/platform-express@11.2.1)
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6)
'@types/express':
specifier: ^5.0.0
version: 5.0.6
@@ -1864,6 +1870,11 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@@ -2602,6 +2613,13 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -6710,6 +6728,10 @@ snapshots:
dependencies:
'@types/node': 24.13.3
'@types/cookie-parser@1.4.10(@types/express@5.0.6)':
dependencies:
'@types/express': 5.0.6
'@types/cookiejar@2.1.5': {}
'@types/deep-eql@4.0.2': {}
@@ -7509,6 +7531,13 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}