You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8359c32
Browse filesBrowse the repository at this point in the historyBrowse 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>
Copy file name to clipboardExpand all lines: CLAUDE.md
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -193,6 +193,8 @@ terraform/state/
193
193
|`scripts/invoke-apply-gate.sh`| CI: invoke gate Lambda for apply credentials |
194
194
|`scripts/run-plan.sh`| CI: terraform plan with exit code handling |
195
195
|`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 |
196
198
197
199
## Naming
198
200
@@ -254,7 +256,9 @@ All parameters are in `eu-central-1`. Use `--profile javabin --region eu-central
254
256
|`/javabin/platform/atlassian-api-key`| SecureString | team-provisioner (Atlassian org admin API key, unscoped — full org admin) |
|`/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) |
258
262
259
263
## GCP Connection
260
264
@@ -296,7 +300,7 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar
296
300
297
301
### Known Issues
298
302
-**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
300
304
-**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
301
305
-**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
302
306
-**Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed.
@@ -226,17 +229,17 @@ Any of `openid`, `email`, `profile`, `phone`, `aws.cognito.signin.user.admin`. T
226
229
|---|---|---|
227
230
| `confidential` | Server-side login (the server does the code exchange) | Client ID **and** secret |
228
231
| `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) |
230
233
231
234
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`.
232
235
233
236
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.
234
237
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.
236
239
237
240
Both forms are supported and produce the same wiring.
238
241
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.
240
243
- `external`— public users.
241
244
- `none`or omitted — no Cognito integration.
242
245
@@ -247,16 +250,42 @@ Both forms are supported and produce the same wiring.
247
250
If `callback_urls` is omitted, defaults to `[https://{routing.host}/, https://{routing.host}/auth/callback]`.
248
251
If `logout_urls` is omitted, defaults to `[https://{routing.host}/]`.
249
252
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
+
250
271
#### How the client is created
251
272
252
273
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:
253
274
254
275
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.
256
277
3. Deleting `auth:` deletes the request parameter, and the Lambda deletes the client and its credentials. A daily reconcile repairs anything an event missed.
257
278
258
279
On the very first deploy with `auth:`, the first task can start a few seconds before the credentials exist; ECS retries it.
259
280
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
+
260
289
#### Injected environment variables
261
290
262
291
| Var | Source | Notes |
@@ -267,6 +296,7 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
| `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 |
270
300
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Only when `groups` is set |
271
301
272
302
`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