diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 0253da2..0bd36f9 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -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. @@ -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=` 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. diff --git a/docs/cognito-google-setup.md b/docs/cognito-google-setup.md index 2fa8aca..c30796a 100644 --- a/docs/cognito-google-setup.md +++ b/docs/cognito-google-setup.md @@ -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`): @@ -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 @@ -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 diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index 7e53838..8f490d9 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -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`). @@ -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 `_` 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 `_` 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`) diff --git a/scripts/generate-modules.py b/scripts/generate-modules.py index cf0cb85..d2acdf5 100755 --- a/scripts/generate-modules.py +++ b/scripts/generate-modules.py @@ -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 diff --git a/terraform/modules/cognito-app-client/outputs.tf b/terraform/modules/cognito-app-client/outputs.tf index d1d877b..6f94464 100644 --- a/terraform/modules/cognito-app-client/outputs.tf +++ b/terraform/modules/cognito-app-client/outputs.tf @@ -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" diff --git a/terraform/modules/service-routing/main.tf b/terraform/modules/service-routing/main.tf index 571b8ed..898b312 100644 --- a/terraform/modules/service-routing/main.tf +++ b/terraform/modules/service-routing/main.tf @@ -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 @@ -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 } } diff --git a/terraform/platform/identity/variables.tf b/terraform/platform/identity/variables.tf index aaf7afd..cd5941a 100644 --- a/terraform/platform/identity/variables.tf +++ b/terraform/platform/identity/variables.tf @@ -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")