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.
This commit is contained in:
34
README.md
34
README.md
@@ -19,25 +19,32 @@ 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 \
|
||||
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner \
|
||||
OIDC_AUDIENCE=travel-planner-api \
|
||||
pnpm --filter backend start:api
|
||||
pnpm --filter frontend start
|
||||
```
|
||||
|
||||
Run pending migrations against the dev database before starting the API for the first time:
|
||||
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
|
||||
|
||||
Set the required OIDC env vars once per shell before running the backend. **`OIDC_CLIENT_SECRET` is a real secret — set it directly in your own shell, never commit it, never paste it 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.
|
||||
|
||||
```bash
|
||||
export DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
|
||||
export REDIS_URL=redis://localhost:6379
|
||||
export OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
||||
export OIDC_AUDIENCE=travel-planner-api
|
||||
pnpm --filter backend build:api && node backend/dist/apps/api/src/migration.js
|
||||
export OIDC_ISSUER=https://auth.forgecore.work
|
||||
export OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
|
||||
export OIDC_AUDIENCE=$OIDC_CLIENT_ID
|
||||
export OIDC_CLIENT_SECRET=<your-client-secret> # never commit this value
|
||||
```
|
||||
|
||||
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
|
||||
Run pending migrations against the dev database (first time only), then start the API and frontend:
|
||||
|
||||
```bash
|
||||
pnpm --filter backend build:api && node backend/dist/apps/api/src/migration.js
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Quality gates
|
||||
|
||||
@@ -76,8 +83,9 @@ 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 (`oidc-client-ts` on the frontend, `jose`-based bearer-token verification on the backend). No local password storage; users are keyed by the OIDC `sub` claim and just-in-time provisioned on first login.
|
||||
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`). New frontend build-time values: `OIDC_ISSUER`, `OIDC_CLIENT_ID` (baked into the production bundle by `docker/edge.Dockerfile`, never read from the container at runtime).
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user