feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token exchange, access token in sessionStorage) with a classic backend-driven BFF: the browser only ever navigates to GET /api/v1/auth/login and is redirected straight to the IdP; PKCE verifier/state live server-side in Redis (SessionStoreService); GET /api/v1/auth/callback (now the registered IdP redirect URI, replacing the frontend's /auth/callback route, which is deleted) verifies the id_token, JIT-provisions the user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax cookie before redirecting into the app. No token material of any kind ever reaches the browser. OidcAuthGuard (per-request bearer JWT verification) is replaced by SessionAuthGuard (cookie -> Redis session lookup) across every controller that used it. cookie-parser is now wired into main.ts. Frontend AuthService shrinks to login()/logout()/ensureSessionChecked(); pkce.ts, auth.interceptor.ts, and the callback component/route are all removed as dead code under this model. New required env var: APP_BASE_URL (source of truth for the OIDC redirect_uri and the post-login redirect target). Verified end-to-end against the real API, Redis, and a mocked IdP: login redirect shape, callback cookie + redirect, state-replay rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
23
README.md
23
README.md
@@ -32,6 +32,7 @@ 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:
|
||||
@@ -43,9 +44,9 @@ 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/auth/callback`.
|
||||
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 Authorization Code + PKCE token exchange happens server-side via `POST /api/v1/auth/session` (see "OIDC client type" below) — the secret never reaches the browser.
|
||||
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
|
||||
|
||||
@@ -85,11 +86,16 @@ Production Docker Compose (`compose.yml`) publishes **exactly one** host port, o
|
||||
## 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:** this deployment's IdP client is confidential (has a client secret), not a plain public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the frontend performs only the browser-side Authorization Code + PKCE redirect (hand-rolled PKCE in `frontend/src/app/auth/pkce.ts`, no `oidc-client-ts` dependency); the resulting `code` + PKCE `code_verifier` are then POSTed to the backend's `POST /api/v1/auth/session` (unauthenticated by design — there is no token yet), which performs the actual code-for-tokens exchange using `OIDC_CLIENT_SECRET` server-side (`TokenExchangeService`) and returns only `{ accessToken, expiresIn }` to the frontend — `refresh_token`/`id_token` are never forwarded. If a future deployment instead uses a public PKCE-only client, this proxy step could be skipped in favor of a direct frontend-to-IdP exchange, but the current IdP requires it.
|
||||
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`; `OIDC_CLIENT_SECRET` is a real secret, never committed). 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).
|
||||
- **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/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 `OidcAuthGuard` (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.
|
||||
- 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`:
|
||||
|
||||
@@ -99,7 +105,10 @@ Production Docker Compose (`compose.yml`) publishes **exactly one** host port, o
|
||||
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 and a mocked IdP (local JWKS + discovery document): missing token → 401, valid token → `/users/me` succeeds and JIT-provisions the user, trip 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.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user