Skip to content
Merged
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
12 changes: 6 additions & 6 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,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 @@ -423,13 +423,13 @@ python3 ../platform/scripts/auth-doctor.py --app-yaml app.yaml # add --json fo

#### 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.
Send the parameters in `COGNITO_EXTRA_AUTH_PARAMS` (today `prompt=select_account`) **and** `identity_provider=<COGNITO_IDENTITY_PROVIDER>` 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. Managed login drops `prompt` when the request does not name the IdP (it still redirects straight to it), so `prompt` alone is not enough. Read the IdP from `COGNITO_IDENTITY_PROVIDER`, never hardcode it: it changes at the IdP cutover.

- 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' } }`
- oidc-client-ts / nuxt-oidc-auth: `extraQueryParams: { prompt: 'select_account', identity_provider: process.env.COGNITO_IDENTITY_PROVIDER }`
- Spring Security: an `OAuth2AuthorizationRequestResolver` that adds `prompt=select_account` and `identity_provider` to `additionalParameters`
- NextAuth / Auth.js: `authorization: { params: { prompt: 'select_account', identity_provider: process.env.COGNITO_IDENTITY_PROVIDER } }`

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.
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 both 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
23 changes: 12 additions & 11 deletions docs/cognito-google-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ 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`):
Expand All @@ -75,13 +75,14 @@ What Terraform configures (`terraform/platform/identity/main.tf`):

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:
forwards `prompt` (except `none`) to the IdP, **but only when the request names it with
`identity_provider=`**: on the single-IdP auto-redirect it drops `prompt` (probe, 2026-10-30). 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 |
| Apps send `prompt=select_account` and `identity_provider=$COGNITO_IDENTITY_PROVIDER` 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 both (the IdP read from the client's `cognito-client-identity-provider`) | `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
Expand Down Expand Up @@ -131,14 +132,14 @@ for extra in "&prompt=select_account" "&prompt=select_account&identity_provider=
done
```

Expected on managed login: the first two go to `accounts.google.com` with `prompt=select_account`; the
last (an app that sends no `prompt`) has none. To probe another IdP (e.g. a replacement OIDC provider),
Result (2026-10-30, managed login, Google-type IdP): all three go straight to `accounts.google.com`,
but only the second carries `prompt=select_account`; with `prompt` alone (no `identity_provider`) and
with nothing, Google gets no `prompt`. `hd=` never appears (Cognito drops it, also when the app sends
it); the Internal consent screen and auth-claims enforce the domain instead. To probe another IdP (e.g. a replacement OIDC provider),
point `internal_idp_probe_identity_providers` at it and use that name in `identity_provider=`.
For app clients, `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).
For app clients, `scripts/auth-doctor.py` sends the pool's `COGNITO_EXTRA_AUTH_PARAMS` plus
`identity_provider` (as apps must) and warns when Google is not asked for the account chooser.
Since `prompt` does reach Google once the IdP is named, no `OIDC`-type replacement provider is needed.

## 4. Cutover: GoogleSAML → Google

Expand Down
4 changes: 2 additions & 2 deletions docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig

**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` = `["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. Platform-owned requests (`/javabin/apps/platform/...`; `platform` is never a registered team) may set `identity_providers` to override the list — the IdP probe client (`platform-idp-probe`, see cognito-google-setup.md) uses it; each IdP must exist in the pool and the internal pool never allows `COGNITO`; the field in any other team's request is invalid. 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).
**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. Platform-owned requests (`/javabin/apps/platform/...`; `platform` is never a registered team) may set `identity_providers` to override the list — the IdP probe client (`platform-idp-probe`, see cognito-google-setup.md) uses it; each IdP must exist in the pool and the internal pool never allows `COGNITO`; the field in any other team's request is invalid. 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`).

Expand Down Expand Up @@ -211,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
6 changes: 4 additions & 2 deletions scripts/generate-modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@ class AppYamlError(ValueError):
# Extra /oauth2/authorize parameters per pool, injected as
# COGNITO_EXTRA_AUTH_PARAMS (URL query string). Internal: Google's account
# chooser, so a browser signed in to a personal Google account can switch to
# the java.no one (managed login forwards prompt to Google). Keep in step with
# internal_auth_params in terraform/modules/service-routing (ALB sign-in).
# the java.no one. Managed login forwards prompt to Google only when the
# request also names the IdP, so apps send identity_provider=
# $COGNITO_IDENTITY_PROVIDER with it (docs/app-yaml-reference.md). Keep in step
# with internal_auth_params in terraform/modules/service-routing (ALB sign-in).
POOL_EXTRA_AUTH_PARAMS = {"internal": "prompt=select_account"}

# Injected when the app calls a peer with scopes / provides scopes
Expand Down
5 changes: 5 additions & 0 deletions terraform/modules/cognito-app-client/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ output "identity_provider_arn" {
value = "${local.param_arn}/cognito-client-identity-provider"
}

output "identity_provider_parameter_name" {
description = "Name of the SSM parameter the platform writes the client's sign-in IdP to"
value = "${local.param_prefix}/cognito-client-identity-provider"
}

output "auth_status_parameter_name" {
description = "SSM parameter where the platform reports the sync result (JSON; read by scripts/auth-doctor.py)"
value = "${local.param_prefix}/auth-status"
Expand Down
24 changes: 17 additions & 7 deletions terraform/modules/service-routing/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ data "aws_ssm_parameter" "login_client_id" {
depends_on = [time_sleep.login_client]
}

# The client's sign-in IdP (GoogleSAML, Google), sent as identity_provider:
# managed login forwards prompt= to the IdP only when the request names it.
data "aws_ssm_parameter" "login_client_idp" {
for_each = local.login_protected

name = module.login_client[each.key].identity_provider_parameter_name
depends_on = [time_sleep.login_client]
}

data "aws_ssm_parameter" "login_client_secret" {
for_each = local.login_protected

Expand Down Expand Up @@ -159,15 +168,16 @@ resource "aws_lb_listener_rule" "login" {
session_cookie_name = local.login_cookie[each.key]
session_timeout = var.login_session_timeout
on_unauthenticated_request = "authenticate"
# No identity_provider parameter: app-auth-sync gives every client the
# pool's sign-in IdP list, and with a single IdP the hosted UI goes
# straight to it. A hardcoded IdP would break at the IdP cutover.
#
# Internal pool: ask Google for its account chooser, so a user whose
# browser is signed in to another Google account can switch instead of
# being refused. Managed login forwards prompt to the IdP; the classic
# hosted UI ignores it.
authentication_request_extra_params = each.value.pool == "internal" ? local.internal_auth_params : null
# being refused. Managed login forwards prompt to the IdP only when the
# request names it (identity_provider): on the single-IdP auto-redirect
# it drops prompt (probed with platform-idp-probe). The IdP comes from
# app-auth-sync's parameter, never hardcoded, so the cutover needs no
# change here. The classic hosted UI ignores prompt.
authentication_request_extra_params = each.value.pool == "internal" ? merge(local.internal_auth_params, {
identity_provider = nonsensitive(data.aws_ssm_parameter.login_client_idp[each.key].value)
}) : null
}
}

Expand Down
2 changes: 1 addition & 1 deletion terraform/platform/identity/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ variable "org_boundary_arn" {
variable "internal_identity_providers" {
description = "Identity providers on internal-pool app clients (GoogleSAML is created outside Terraform)"
type = list(string)
default = ["GoogleSAML"]
default = ["Google"]

validation {
condition = length(var.internal_identity_providers) > 0 && !contains(var.internal_identity_providers, "COGNITO")
Expand Down
Loading