Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ What happens:
- Sessions last 8 hours. A registry change (e.g. removing someone from a group) takes effect at their next sign-in, so at most 8 hours later.
- An app.yaml change to a rule's `pool` or `groups`, or a reorder that gives a rule a new position, renames that rule's session cookie, so everyone signs in again and the new groups apply at once. Changing only `paths` keeps existing sessions.
- One sign-in covers all rules of the app: Cognito remembers the user, so a second rule only redirects.
- Internal pool: the load balancer sends `prompt=select_account`, so Google shows its account chooser (once the pool domain is on managed login; see `docs/cognito-google-setup.md`).

Rules and priorities:

Expand Down Expand Up @@ -359,7 +360,7 @@ Changing `client` between `confidential` and `public` replaces the Cognito clien

Both forms are supported and produce the same wiring.

- `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.
- `internal` — Javabin heroes. Every internal client signs in through the pool's IdP list (`internal_identity_providers` in `terraform/platform/identity/`): `Google` (Google OIDC; `GoogleSAML` before the cutover). 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.
- `external` — public users. Tokens carry only native Cognito groups of the external pool; registry groups are never added there, since anyone can sign up with any email.
- `none` or omitted — no Cognito integration.

Expand Down Expand Up @@ -418,6 +419,17 @@ python3 ../platform/scripts/auth-doctor.py --app-yaml app.yaml # add --json fo
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | `confidential` only |
| `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 |
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Only when `groups` is set |
| `COGNITO_EXTRA_AUTH_PARAMS` | Fixed per pool, URL query string | Internal pool, `confidential` and `public` only: `prompt=select_account`. Add it to your `/oauth2/authorize` request (see below) |

#### Account chooser (internal pool)

Send the parameters in `COGNITO_EXTRA_AUTH_PARAMS` (today `prompt=select_account`) with every sign-in redirect to `/oauth2/authorize`. With them, Google shows its account chooser, so a hero whose browser is signed in to a personal Google account can pick the `@java.no` one instead of being refused. Without them, Google silently uses the active account.

- oidc-client-ts / nuxt-oidc-auth: `extraQueryParams: { prompt: 'select_account' }`
- Spring Security: an `OAuth2AuthorizationRequestResolver` that adds `prompt=select_account` to `additionalParameters`
- NextAuth / Auth.js: `authorization: { params: { prompt: 'select_account' } }`

Cognito forwards `prompt` only when the pool domain uses managed login; the classic hosted UI ignores it, so sending it is always safe. ALB sign-in (`routing.login`) sends it for you.

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

Expand Down
81 changes: 57 additions & 24 deletions docs/cognito-google-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ pick another account).
## Why OIDC, not SAML

Google's SAML IdP always answers for the browser's *active* Google session and has no account chooser.
The classic hosted UI drops `prompt=`, so Cognito cannot ask for one either. The Google OIDC provider
can: its `authorize_url` carries `prompt=select_account`, and the OAuth client lives in a GCP project
with an **Internal** consent screen, so Google itself refuses non-java.no accounts on a page that lets
the user switch account.
The Google provider can: with `prompt=select_account` Google shows its account chooser, and the
OAuth client lives in a GCP project with an **Internal** consent screen, so Google itself refuses
non-java.no accounts on a page that lets the user switch account. Getting `prompt` to Google needs
managed login on the pool domain (step 3).

| Integration | Purpose | Credential | Stored in |
|---|---|---|---|
Expand Down Expand Up @@ -60,45 +60,78 @@ once both parameters exist. Until then the plan creates nothing, which is why th
first.

Adding the IdP changes **no app client**: clients use only the IdPs in `internal_identity_providers`
(`terraform/platform/identity/variables.tf`, default `["GoogleSAML"]`). GoogleSAML keeps
(`terraform/platform/identity/variables.tf`, default `["GoogleSAML"]` until the cutover in step 4). GoogleSAML keeps
working.

What Terraform configures (`terraform/platform/identity/main.tf`):

- `authorize_scopes = "openid email profile"`
- `authorize_url = https://accounts.google.com/o/oauth2/v2/auth?hd=java.no&prompt=select_account`
(`hd=` preselects java.no accounts; it is only a hint)
- `authorize_url = https://accounts.google.com/o/oauth2/v2/auth`, plain: Cognito drops query
parameters from a Google-type `authorize_url` (`describe-identity-provider` shows the URL without
them, and every plan showed a diff), so `prompt` has to come from the app (step 3)
- attribute mapping `email`, `email_verified`, `name`, `username = sub`

## 3. Verify the account chooser survives (after the IdP exists)

Cognito keeping query parameters in a `Google`-type `authorize_url` is not documented. Probe it with
any internal client ID and one of its callback URLs (no credentials needed; cupcake's are in
`/javabin/apps/cupcake/cupcake/{cognito-client-id,auth}`):
## 3. Account chooser: managed login + `prompt=select_account`

Cognito drops query parameters from the provider's `authorize_url`, and the classic hosted UI
(`ManagedLoginVersion` 1) ignores a `prompt` parameter sent by the app. Managed login (version 2)
forwards every `prompt` value except `none` to the IdP the user signs in with. So:

| Piece | Where | State |
|---|---|---|
| A managed-login branding style per app client (Cognito defaults) | `app-auth-sync` (created with the client, backfilled by reconcile, deleted with the client) | automatic |
| Apps send `prompt=select_account` to `/oauth2/authorize` | `COGNITO_EXTRA_AUTH_PARAMS` (generate-modules.py); SPA `extraQueryParams` etc. — see app-yaml-reference "Account chooser" | per app |
| ALB sign-in (`routing.login`) sends it | `authentication_request_extra_params` in `terraform/modules/service-routing` | automatic, internal pool |
| Internal pool domain on managed login | `internal_managed_login_version = 2` in `terraform/platform/identity/variables.tf` (an in-place update of the domain; 1 = back to the classic UI) | done |

The external pool's domains stay on the classic hosted UI. Nothing there needs `prompt`, and its
clients get branding styles too, so it can move later with the same one-line change.

**Order matters.** On a managed-login domain a client without a branding style has no working
sign-in page. The style is harmless on the classic UI, so styles go first:

1. Merge the app-auth-sync change. Platform CI queues a reconcile after the apply, which gives every
existing client (including `login-N` slot clients) a style. Check every internal client:
```bash
POOL=$(aws cognito-idp list-user-pools --max-results 60 \
--query "UserPools[?Name=='javabin-internal'].Id" --output text)
for c in $(aws cognito-idp list-user-pool-clients --user-pool-id $POOL --max-results 60 \
--query 'UserPoolClients[].ClientId' --output text); do
aws cognito-idp describe-managed-login-branding-by-client --user-pool-id $POOL --client-id $c \
--query ManagedLoginBranding.ManagedLoginBrandingId --output text >/dev/null 2>&1 \
&& echo "$c ok" || echo "$c MISSING STYLE"
done
```
Clients made outside app-auth-sync (e.g. app-peers-sync's `m2m.*` client-credentials clients) use
no sign-in page and need no style.
2. PR: `internal_managed_login_version = 2`. The plan must show an in-place update of
`aws_cognito_user_pool_domain.internal` only (no pool, domain or client replacement).
3. After apply: open an app's sign-in (or run the probe below) and confirm the managed-login page or a
straight redirect to the IdP, not an error page.

Probe (unauthenticated GET, no credentials; use any internal client ID and one of its callback URLs —
cupcake's are in `/javabin/apps/cupcake/cupcake/{cognito-client-id,auth}`):

```bash
DOMAIN=javabin-internal.auth.eu-central-1.amazoncognito.com
curl -s -o /dev/null -w '%{redirect_url}\n' \
"https://$DOMAIN/oauth2/authorize?response_type=code&client_id=<id>&redirect_uri=<callback>&scope=openid&identity_provider=Google" \
"https://$DOMAIN/oauth2/authorize?response_type=code&client_id=<id>&redirect_uri=<callback>&scope=openid&identity_provider=Google&prompt=select_account" \
| grep -q 'prompt=select_account' && echo OK || echo "prompt dropped"
```

`identity_provider=Google` works before cutover only if the client lists `Google`; before cutover, use
a test client (or run the probe right after cutover — `scripts/auth-doctor.py` does the same check and
warns when `prompt=select_account` is missing).

**If the parameters are dropped (fallback):** set `google_authorize_url_params = false`, switch the
domain to managed login (`aws_cognito_user_pool_domain.internal` → `managed_login_version = 2`), give
every client a branding style (app-auth-sync: `CreateManagedLoginBranding(UseCognitoProvidedValues=True)`),
and send `prompt=select_account` from the app (SPA `extraQueryParams`, ALB
`authentication_request_extra_params`). Managed login forwards `prompt` to OIDC IdPs; the classic UI
does not.
`identity_provider=Google` works only for a client that lists `Google`, so before the cutover this
needs a client with `Google` (or run it right after cutover). `scripts/auth-doctor.py` sends the pool's
`COGNITO_EXTRA_AUTH_PARAMS` the same way and warns when Google is not asked for the account chooser.
If `prompt` still does not reach Google on managed login, the Google-type provider is the problem:
replace it with an `OIDC`-type provider for `https://accounts.google.com` (Cognito documents
forwarding `prompt` for OIDC IdPs).

## 4. Cutover: GoogleSAML → Google

Prerequisites:

- The Google IdP exists (step 2) and the probe passes (step 3).
- The Google IdP exists (step 2), the internal domain is on managed login and the probe passes
(step 3).
- **Hard requirement:** server-side domain enforcement is live — the internal-pool lockdown (#166:
auth-claims `REQUIRED_EMAIL_DOMAIN`, no self sign-up, no `COGNITO` on internal clients). The Internal
consent screen already blocks other Google accounts, but the pool must not rely on one GCP setting.
Expand Down
8 changes: 5 additions & 3 deletions docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,13 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig

**Slots:** a request one level down, `/javabin/apps/{team}/{app}/login-N/auth` (written by service-routing for each `routing.login` rule), gets its own client `{team}-{app}.login-N`; its credentials, `cognito-client-identity-provider` and `auth-status` go under `/javabin/apps/{team}/{app}/login-N/`. `.` cannot occur in app names, so slot clients never collide with another app's client. Other subpaths are ignored.

**Identity providers:** every client in a pool gets exactly the pool's list, from `INTERNAL_IDENTITY_PROVIDERS` / `EXTERNAL_IDENTITY_PROVIDERS` (comma-separated; set from the identity module's `internal_identity_providers` = `["GoogleSAML"]` and `external_identity_providers` = `["COGNITO"]`). With a single IdP the hosted UI redirects straight to it, so internal apps show no password form or sign-up link. Reconcile overwrites console edits to the list (reported as drift). If a configured IdP does not exist in the pool, the request is skipped with an `ERROR` log and status `error` (reconcile lists it under `error`): nothing is created, moved or changed, and the existing client keeps working. Internal-pool requests asking for `aws.cognito.signin.user.admin` are rejected as invalid (that scope would let a user rewrite the email auth-claims trusts).
**Managed-login branding:** after the credentials, every client gets a managed-login style with Cognito's defaults (`CreateManagedLoginBranding`, `UseCognitoProvidedValues=True`), only if `DescribeManagedLoginBrandingByClient` finds none, so a style customised in the console is never changed. A pool domain on managed login (`ManagedLoginVersion` 2) has no sign-in page for a client without a style; on the classic hosted UI the style is unused. Deleting a client (request gone, pool switch, `client_type` change, orphan) deletes its style first. Reconcile backfills styles for existing clients. auth-status `branding`: `created` / `exists`. A failure here comes after the credentials are written but still makes the sync `error` (alarm).

**Identity providers:** every client in a pool gets exactly the pool's list, from `INTERNAL_IDENTITY_PROVIDERS` / `EXTERNAL_IDENTITY_PROVIDERS` (comma-separated; set from the identity module's `internal_identity_providers` = `["Google"]` and `external_identity_providers` = `["COGNITO"]`). With a single IdP the hosted UI redirects straight to it, so internal apps show no password form or sign-up link. Reconcile overwrites console edits to the list (reported as drift). If a configured IdP does not exist in the pool, the request is skipped with an `ERROR` log and status `error` (reconcile lists it under `error`): nothing is created, moved or changed, and the existing client keeps working. Internal-pool requests asking for `aws.cognito.signin.user.admin` are rejected as invalid (that scope would let a user rewrite the email auth-claims trusts).

**Errors:** reconcile syncs every app even if one fails, and returns normally (raising would make the async EventBridge/CI invoke retry the whole run twice). The CloudWatch alarm `javabin-app-auth-sync-skipped` (metric filter on `client left unchanged` or `Sync failed for`) posts to `javabin-alerts` for a skipped request or a failed app. A failing change event still raises (Lambda `Errors`).

**auth-status** (`/javabin/apps/{team}/{app}/auth-status`, String JSON, team-readable): `status` (`synced` / `invalid` / `error`), `error`, `client_id`, `client_id_changed`, `client_id_changed_at` (when the ID last changed, carried over between syncs), `client_type`, `pool`, `pool_domain`, `identity_providers`, `identity_provider`, `callback_urls`, `logout_urls`, `scopes`, `allowed_groups`, `unknown_groups` (no `group#` item in the memberships table), `empty_groups`, `changed_fields`, `drift_corrected` (fields a *reconcile* changed back: someone edited the client outside the platform; also logged as `WARN drift`), `request_version`, `synced_at`. When the JSON would exceed 4096 bytes, list fields are dropped (URLs first) and `truncated: true` is set; it is always valid JSON. `scripts/auth-doctor.py` reads it.
**auth-status** (`/javabin/apps/{team}/{app}/auth-status`, String JSON, team-readable): `status` (`synced` / `invalid` / `error`), `error`, `client_id`, `client_id_changed`, `client_id_changed_at` (when the ID last changed, carried over between syncs), `client_type`, `pool`, `pool_domain`, `identity_providers`, `identity_provider`, `callback_urls`, `logout_urls`, `scopes`, `allowed_groups`, `unknown_groups` (no `group#` item in the memberships table), `empty_groups`, `changed_fields`, `branding`, `drift_corrected` (fields a *reconcile* changed back: someone edited the client outside the platform; also logged as `WARN drift`), `request_version`, `synced_at`. When the JSON would exceed 4096 bytes, list fields are dropped (URLs first) and `truncated: true` is set; it is always valid JSON. `scripts/auth-doctor.py` reads it.

**Who can write it:** the team SSO permission sets (`javabin-team-{team}`, and `javabin-developer` in `terraform/org`) deny writes to `auth-status`, `cognito-client-*`, `auth` and `db-master-password`. Team CI roles and gate-vended apply credentials (developer boundary, which allows `ssm:PutParameter` under `/javabin/apps/{team}/*`) can still write them, as they always could for `cognito-client-*`. The effect stays inside the team's own app: a forged auth-status, or a changed credential that the next sync puts back (and one extra restart). Treat auth-status as a report, not an authority.

Expand Down Expand Up @@ -209,7 +211,7 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
**Trigger:** Cognito pre-token-generation (V2) on the internal pool (`javabin-internal`), every sign-in and token refresh
**Purpose:** Refuses tokens for accounts outside the Workspace domain, puts the user's registry groups into `cognito:groups` (ID and access token), and refuses sign-in when the app client has `allowed_groups` and the user is in none of them.

**Domain check:** with `REQUIRED_EMAIL_DOMAIN` set (internal pool: `var.google_workspace_domain`, `java.no`), a token is issued only when the email ends with `@java.no` (case-insensitive) **and** the user signed in through one of `FEDERATED_IDENTITY_PROVIDERS` (the internal pool's IdP list, `GoogleSAML`) or has `email_verified=true`. Federated users are recognised by the `identities` attribute, or by their `<Provider>_` username. Anyone else gets "Sign in with your @java.no account." on the app's callback. Every IdP in that list is trusted to vouch for the email (no `email_verified` check), so it must release only Workspace accounts; see the comment on `internal_identity_providers` before adding one. Exception: IdPs in `EMAIL_VERIFIED_IDENTITY_PROVIDERS` (default `Google`, the OIDC IdP) also need `email_verified=true`, which the pool maps from Google's claim; the identity module refuses `Google` in the list until its OAuth client is in SSM and `google_workspace_domain` is set. A function without `REQUIRED_EMAIL_DOMAIN` skips the check. The internal pool also has self sign-up disabled (`allow_admin_create_user_only`).
**Domain check:** with `REQUIRED_EMAIL_DOMAIN` set (internal pool: `var.google_workspace_domain`, `java.no`), a token is issued only when the email ends with `@java.no` (case-insensitive) **and** the user signed in through one of `FEDERATED_IDENTITY_PROVIDERS` (the internal pool's IdP list, `Google`) or has `email_verified=true`. Federated users are recognised by the `identities` attribute, or by their `<Provider>_` username. Anyone else gets "Sign in with your @java.no account." on the app's callback. Every IdP in that list is trusted to vouch for the email (no `email_verified` check), so it must release only Workspace accounts; see the comment on `internal_identity_providers` before adding one. Exception: IdPs in `EMAIL_VERIFIED_IDENTITY_PROVIDERS` (default `Google`, the OIDC IdP) also need `email_verified=true`, which the pool maps from Google's claim; the identity module refuses `Google` in the list until its OAuth client is in SSM and `google_workspace_domain` is set. A function without `REQUIRED_EMAIL_DOMAIN` skips the check. The internal pool also has self sign-up disabled (`allow_admin_create_user_only`).

**Environment vars:** `MEMBERSHIPS_TABLE`, `REQUIRED_EMAIL_DOMAIN`, `FEDERATED_IDENTITY_PROVIDERS`, `EMAIL_VERIFIED_IDENTITY_PROVIDERS` (optional, default `Google`)

Expand Down
Loading
Loading