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

Travel Planner

Self-hosted, AI-assisted travel planner. Angular PWA frontend, NestJS API + background worker, PostgreSQL, Redis/BullMQ, and a Mistral-backed travel agent behind a strict tool boundary.

.env.example lists configuration names and non-secret defaults only. Real production secrets (database password, SMTP credentials, LLM API keys, etc.) are never committed and live only on the deployment host / secret store, supplied to containers as environment variables.

Repository layout

frontend/   Angular PWA (pnpm workspace member)
backend/    NestJS workspace: apps/api, apps/worker, libs/*
docker/     Production Dockerfiles and edge (Nginx) config
scripts/    Compose-invariant checks and TeamCity entry points
docs/       Specs, plans, and architecture documentation

Developer setup

pnpm install
pnpm dev:infra

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.

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:

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

pnpm lint
pnpm test
pnpm test:compose
pnpm build

TeamCity deployment scripts

TeamCity invokes repository-owned scripts rather than duplicating deployment logic in step configuration:

scripts/teamcity/validate.sh       # install, lint, test, compose-invariant check, build
scripts/teamcity/build-images.sh   # build + push immutable IMAGE_TAG images
scripts/teamcity/deploy.sh         # pull, migrate, compose up -d, smoke test
scripts/teamcity/smoke.sh          # health/root checks against APP_BASE_URL
scripts/teamcity/rollback.sh       # redeploy the previous IMAGE_TAG

deploy.sh never runs docker compose down as part of a routine deployment; rollback.sh never attempts an automatic database downgrade.

Production topology

Production Docker Compose (compose.yml) publishes exactly one host port, on the edge (Nginx) service, which serves the built Angular app and reverse-proxies /api/* and /health/* to the internal api service. api, worker, postgres, and redis are reachable only over the internal Docker network. See docs/architecture/deployment.md for the full contract and scripts/teamcity/ for the TeamCity-invoked build/deploy/rollback scripts.

Phase 01 status: foundation complete

  • GET /health/live — process liveness only, no dependency checks.
  • GET /health/ready — validates PostgreSQL and Redis connectivity.
  • 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:

    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.

Description
No description provided
Readme 8.5 MiB
Languages
HTML 81%
TypeScript 15.1%
JavaScript 3.6%
SCSS 0.3%