Skip to content

Commit 8359c32

Browse files
feat(auth): gated Google OIDC IdP, auth-status, URL validation and auth doctor
Builds on the internal-pool lockdown (#166) and reuses its per-pool IdP list (internal_identity_providers, INTERNAL_IDENTITY_PROVIDERS, identity_providers(pool, upid), PoolMisconfigured), so one list drives both the app clients and auth-claims' trusted IdPs. - identity: Google OIDC IdP on the internal pool, created only once its OAuth client is in SSM (/javabin/platform/google-oauth/internal/*); authorize_url forces the account chooser (hd=java.no&prompt=select_account), email_verified mapped. Precondition: "Google" in internal_identity_providers needs the credentials and google_workspace_domain. - auth-claims: Google users also need email_verified=true (EMAIL_VERIFIED_IDENTITY_PROVIDERS, default Google). - app-auth-sync: write cognito-client-* only on change, add cognito-client-identity-provider, publish team-readable auth-status (status, client, client_id_changed_at, IdPs, drift corrected, unknown groups; always valid JSON under 4096 bytes); reject URL fragments; reconcile keeps going past a failing app and returns normally (logged for the skipped alarm) instead of raising into async retries. - app-secret-rollout: restart on cognito-client-id / -identity-provider changes; ignore auth-status and cognito-client-secret (a new secret always comes with a new ID). - generate-modules: validate callback/logout hosts (routing.host or auth.extra_hosts), https except localhost, no fragments; reject client: none with URLs/scopes; inject COGNITO_IDENTITY_PROVIDER. - scripts/auth-doctor.py, run non-blocking after app apply (FAIL on a non-JSON status, short wait when auth-status is missing, task check is a WARN against client_id_changed_at for PRIMARY tasks only); platform CI queues a reconcile after apply. - Docs: GCP setup with the correct redirect URI, cutover plan with the lockdown as a hard prerequisite; team CI roles can still write platform-managed parameters (documented); SAML doc marked superseded. Part of #157 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6b90c3e commit 8359c32

30 files changed

Lines changed: 1523 additions & 275 deletions

‎.github/workflows/platform-ci.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ jobs:
239239
working-directory: ${{ env.TF_ROOT }}
240240
run: terraform apply -auto-approve -lock-timeout=5m tfplan
241241

242+
# Re-sync app Cognito clients with the Lambda/config just applied, so
243+
# parameters it adds (cognito-client-identity-provider, auth-status)
244+
# exist before an app redeploys. Async; unchanged values are not
245+
# rewritten, so nothing restarts. Non-blocking: the 03:30 reconcile
246+
# catches up anyway.
247+
- name: Reconcile app Cognito clients
248+
continue-on-error: true
249+
run: sh scripts/reconcile-app-auth.sh
250+
242251
# --------------------------------------------------------------------------
243252
# Drift Detection — scheduled weekly, plan-only
244253
# --------------------------------------------------------------------------

‎.github/workflows/tf-apply.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,13 @@ jobs:
9393
- name: Terraform Apply
9494
working-directory: ${{ inputs.tf_root }}
9595
run: terraform apply -auto-approve tfplan
96+
97+
# Non-blocking: checks Cognito sign-in right after apply (sync errors,
98+
# redirect_mismatch, logout URL mismatch, unknown auth.groups). Waits
99+
# for app-auth-sync to process the new request. Skips apps without auth.
100+
- name: Auth doctor
101+
if: hashFiles('app.yaml') != ''
102+
continue-on-error: true
103+
env:
104+
APP_SERVICE: ${{ github.event.repository.name }}
105+
run: python3 .platform/scripts/auth-doctor.py --app-yaml app.yaml --wait 180

‎CLAUDE.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ terraform/state/
193193
| `scripts/invoke-apply-gate.sh` | CI: invoke gate Lambda for apply credentials |
194194
| `scripts/run-plan.sh` | CI: terraform plan with exit code handling |
195195
| `scripts/upload-plan.sh` | CI: upload plan artifact to S3 |
196+
| `scripts/auth-doctor.py` | CI (after app apply) / local: checks an app's Cognito sign-in end to end from auth-status + HTTP probes (tests: `scripts/tests/`) |
197+
| `scripts/reconcile-app-auth.sh` | CI (after platform apply): queue an app-auth-sync reconcile |
196198

197199
## Naming
198200

@@ -254,7 +256,9 @@ All parameters are in `eu-central-1`. Use `--profile javabin --region eu-central
254256
| `/javabin/platform/atlassian-api-key` | SecureString | team-provisioner (Atlassian org admin API key, unscoped — full org admin) |
255257
| `/javabin/platform-overrides/{repo}/{sha}` | SecureString | Risk gate override tokens (single-use) |
256258
| `/javabin/apps/{team}/{app}/auth` | String | app.yaml `auth:` request (written by app TF, read by app-auth-sync) |
257-
| `/javabin/apps/{team}/{app}/cognito-client-{id,secret}` | String / SecureString | Written by app-auth-sync, read by ECS execution role |
259+
| `/javabin/apps/{team}/{app}/cognito-client-{id,secret,identity-provider}` | String / SecureString / String | Written by app-auth-sync (only on change; a change restarts the app), read by ECS execution role |
260+
| `/javabin/apps/{team}/{app}/auth-status` | String (JSON) | Written by app-auth-sync, read by the team and `scripts/auth-doctor.py` |
261+
| `/javabin/platform/google-oauth/internal/{client-id,client-secret}` | String / SecureString | Internal pool's Google OIDC IdP (identity TF; IdP is created only when both exist) |
258262

259263
## GCP Connection
260264

@@ -296,7 +300,7 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar
296300

297301
### Known Issues
298302
- **ECS deploy stabilization**: platform-test-app task registers but service fails health check
299-
- **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform
303+
- **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform. It is replaced by the Terraform-managed Google OIDC IdP at cutover (docs/cognito-google-setup.md), then deleted
300304
- **SSO sessions carry no `team` tag**: the Identity Center ABAC attribute (`${path:enterprise.team}`) is never set on users, so `${aws:PrincipalTag/team}` is empty for humans. Per-team permission sets (`javabin-team-{team}`) therefore use `javabin-team-{team}-guardrails` (the developer boundary rendered with the team name) instead. The ABAC-based `javabin-developer` permission set in `terraform/org` cannot reach team resources
301305
- **App databases share one network path**: service-rds allows ingress from the shared ECS tasks SG, so every team's tasks can reach every team's DB port (password still required). Fix: per-app security groups
302306
- **Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed.

‎docs/app-yaml-reference.md‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,13 @@ Full form:
200200
auth:
201201
pool: internal # 'internal' (default) or 'external'
202202
client: confidential # confidential (default) | public | none — see below
203-
callback_urls: # optional; https, or http://localhost for local dev
203+
callback_urls: # optional; https on routing.host / extra_hosts, or http://localhost
204204
- https://app.javazone.no/auth/callback
205205
- http://localhost:3000/auth/callback
206206
logout_urls: # optional; same rules
207207
- https://app.javazone.no/
208+
extra_hosts: # optional — other hosts the app serves callbacks on
209+
- www.app.javazone.no
208210
scopes: # optional, default ['openid', 'email', 'profile']
209211
- openid
210212
- email
@@ -214,6 +216,7 @@ auth:
214216
env: # optional — also inject values under your own names
215217
COGNITO_CLIENT_ID: NUXT_PUBLIC_OIDC_CLIENT_ID
216218
COGNITO_ISSUER_URL: NUXT_PUBLIC_OIDC_AUTHORITY
219+
COGNITO_IDENTITY_PROVIDER: NUXT_PUBLIC_OIDC_IDENTITY_PROVIDER
217220
```
218221

219222
#### `scopes`
@@ -226,17 +229,17 @@ Any of `openid`, `email`, `profile`, `phone`, `aws.cognito.signin.user.admin`. T
226229
|---|---|---|
227230
| `confidential` | Server-side login (the server does the code exchange) | Client ID **and** secret |
228231
| `public` | Browser apps doing OIDC with PKCE (oidc-client-ts, etc.) — a secret can't be kept in a browser | Client ID, no secret |
229-
| `none` | APIs that only *verify* tokens issued to another app's client | Pool/issuer values only; no client, no callbacks, no `routing.host` needed |
232+
| `none` | APIs that only *verify* tokens issued to another app's client | Pool/issuer values only; no client, no callbacks, no `routing.host` needed. `callback_urls`, `logout_urls`, `scopes` and `extra_hosts` are rejected (they would be ignored) |
230233

231234
A typical split frontend/backend: the frontend uses `client: public`, the API uses `client: none` and validates tokens against `COGNITO_ISSUER_URL` / `COGNITO_WELL_KNOWN_URL`.
232235

233236
A `client: none` API has no client ID of its own to check, so it accepts tokens issued to *any* client in the pool. Check `cognito:groups` (or the token's `client_id`) for anything that matters.
234237

235-
Changing `client` between `confidential` and `public` replaces the Cognito client, so its ID changes. Running tasks keep the old ID until they restart: redeploy after the change.
238+
Changing `client` between `confidential` and `public` replaces the Cognito client, so its ID changes. The platform restarts the service when the new ID is written (app-secret-rollout), so running tasks don't keep the deleted ID. SPAs that bake the client ID in at *build* time (Vite `VITE_*`, Next `NEXT_PUBLIC_*` at build) must rebuild; runtime config (Nuxt `NUXT_PUBLIC_*`) picks it up on restart.
236239

237240
Both forms are supported and produce the same wiring.
238241

239-
- `internal` — Javabin heroes. The hosted UI sends users straight to Google Workspace (`GoogleSAML`), so apps don't need an `identity_provider` parameter. There is no password form or sign-up. Only `@java.no` accounts get a token; anyone else is refused at sign-in.
242+
- `internal` — Javabin heroes. Every internal client signs in through the pool's IdP list (`internal_identity_providers` in `terraform/platform/identity/`): today Google Workspace SAML (`GoogleSAML`), after the Google cutover only `Google`. With a single IdP the hosted UI sends users straight to it, so apps don't need an `identity_provider` parameter; apps that pass one anyway should read `COGNITO_IDENTITY_PROVIDER` instead of hardcoding `GoogleSAML`. There is no password form or sign-up. Only `@java.no` accounts get a token; anyone else is refused at sign-in.
240243
- `external` — public users.
241244
- `none` or omitted — no Cognito integration.
242245

@@ -247,16 +250,42 @@ Both forms are supported and produce the same wiring.
247250
If `callback_urls` is omitted, defaults to `[https://{routing.host}/, https://{routing.host}/auth/callback]`.
248251
If `logout_urls` is omitted, defaults to `[https://{routing.host}/]`.
249252

253+
An explicit list *replaces* the defaults. Apps that redirect elsewhere (Spring: `/login/oauth2/code/cognito`, NextAuth: `/api/auth/callback/cognito`) must list that path.
254+
255+
#### URL rules (checked when terraform/ is generated)
256+
257+
- `https://`, except `http://localhost` / `http://127.0.0.1` for local development.
258+
- The host must be `routing.host` or listed in `auth.extra_hosts` (localhost excepted), so a typo fails in the PR instead of as `redirect_mismatch` in the browser.
259+
- No `#fragment` (also rejected by `app-auth-sync`).
260+
261+
The host rule runs when `app.yaml` is expanded (`generate-modules.py`); the platform Lambda checks only the scheme and fragments, so it is a guard against typos, not a security boundary.
262+
263+
#### Logout
264+
265+
Cognito's `/logout?client_id=…&logout_uri=…` redirects back only when `logout_uri` matches a `logout_urls` entry **exactly** (trailing slash and path included). Anything else lands on the hosted UI's `/login` page. Cognito ignores `post_logout_redirect_uri`; send `logout_uri`.
266+
267+
#### Sign-in denied
268+
269+
A user in none of `auth.groups` gets no token: Cognito redirects to your callback with `error=access_denied&error_description=PreTokenGeneration failed with error You are not in a group that has access to this app.` Render `error_description` on your callback page instead of looping back to sign-in.
270+
250271
#### How the client is created
251272

252273
App CI cannot create Cognito clients: the pools belong to the platform, and a grant on a pool would let any team rewrite every other team's client. So the app only *requests* one:
253274

254275
1. The generated Terraform writes the client config to SSM `/javabin/apps/{team}/{name}/auth`. The permission boundary lets a team write only under its own prefix, so the path proves ownership.
255-
2. The platform Lambda `javabin-app-auth-sync` reacts to the change (EventBridge), creates or updates the client `{team}-{name}` in the requested pool, and writes `/javabin/apps/{team}/{name}/cognito-client-id` and `cognito-client-secret`.
276+
2. The platform Lambda `javabin-app-auth-sync` reacts to the change (EventBridge), creates or updates the client `{team}-{name}` in the requested pool, and writes `/javabin/apps/{team}/{name}/cognito-client-id`, `cognito-client-secret` and `cognito-client-identity-provider` (only when a value changes; a change restarts the service). It reports the outcome in `/javabin/apps/{team}/{name}/auth-status` (JSON, readable by the team): `status` (`synced` / `invalid` / `error` + `error`), `client_id`, `identity_providers`, `drift_corrected` (client settings someone changed outside the platform, reverted by the daily reconcile), `unknown_groups` (`auth.groups` entries that don't exist in the registry — nobody can sign in through them), `client_id_changed_at`, `synced_at`. Your team's SSO role cannot write it, but your CI role can (as it can the `cognito-client-*` parameters); the next sync overwrites any such change, so treat it as a report.
256277
3. Deleting `auth:` deletes the request parameter, and the Lambda deletes the client and its credentials. A daily reconcile repairs anything an event missed.
257278

258279
On the very first deploy with `auth:`, the first task can start a few seconds before the credentials exist; ECS retries it.
259280

281+
#### auth doctor
282+
283+
`scripts/auth-doctor.py` (platform repo) checks the whole chain with the team's own credentials: the request matches app.yaml, `auth-status` is `synced` with the same client ID, `/oauth2/authorize` redirects to the IdP for every callback URL (no `redirect_mismatch`), `/logout` returns to every logout URL, and (warning only) the service's current deployment started after the client ID last changed. App CI runs it after `terraform apply` (non-blocking; failures show as annotations). Locally, from the app repo:
284+
285+
```bash
286+
python3 ../platform/scripts/auth-doctor.py --app-yaml app.yaml # add --json for scripts
287+
```
288+
260289
#### Injected environment variables
261290

262291
| Var | Source | Notes |
@@ -267,6 +296,7 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
267296
| `COGNITO_WELL_KNOWN_URL` | `{issuer}/.well-known/openid-configuration` | OIDC discovery document |
268297
| `COGNITO_CLIENT_ID` | SSM (ECS task `secrets`) | `confidential` and `public` only |
269298
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | `confidential` only |
299+
| `COGNITO_IDENTITY_PROVIDER` | SSM (ECS task `secrets`) | `confidential` and `public` only. The IdP the client signs in through (`GoogleSAML`, `Google`, or `COGNITO`), for apps that send `identity_provider` themselves. Changes at the IdP cutover, which restarts the service |
270300
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Only when `groups` is set |
271301

272302
`auth.env` adds the same values under extra names (both names are injected). Only names injected for your `client` type can be aliased.

0 commit comments

Comments
 (0)