From 3b1d78608b4e73716f24df0811f2d8e4714316ea Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Thu, 29 Oct 2026 22:45:37 +0100 Subject: [PATCH 1/3] feat(identity): managed-login branding per app client and prompt=select_account app-auth-sync gives every client it manages (including login-N slot clients) a managed-login branding style with Cognito's defaults, only when it has none, and deletes it with the client; reconcile backfills existing clients. The role may create/describe/delete styles on the two pools only. Prepares the internal pool domain for managed login, which forwards prompt=select_account to Google (the classic hosted UI ignores it): - internal_managed_login_version (default 1, unchanged) gates the switch - the Google provider's authorize_url is plain: Cognito dropped the query string, which left a perpetual plan diff - ALB sign-in rules on the internal pool send prompt=select_account - apps get COGNITO_EXTRA_AUTH_PARAMS=prompt=select_account (internal pool) - auth-doctor sends the same parameters in its authorize probe Co-Authored-By: Claude Opus 5.5 --- docs/app-yaml-reference.md | 12 ++ docs/cognito-google-setup.md | 79 +++++++--- docs/lambda-functions.md | 4 +- scripts/auth-doctor.py | 18 ++- scripts/generate-modules.py | 9 ++ scripts/tests/test_auth_doctor.py | 9 ++ scripts/tests/test_generate_modules.py | 13 ++ terraform/lambda-src/app_auth_sync/handler.py | 59 +++++++- .../lambda-src/tests/test_app_auth_sync.py | 143 ++++++++++++++++++ terraform/modules/service-routing/main.tf | 10 ++ terraform/platform/identity/main.tf | 26 ++-- terraform/platform/identity/variables.tf | 17 ++- terraform/platform/lambdas/main.tf | 12 ++ 13 files changed, 360 insertions(+), 51 deletions(-) diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index aebd3fa..0253da2 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -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: @@ -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. diff --git a/docs/cognito-google-setup.md b/docs/cognito-google-setup.md index f84b6df..8d09b58 100644 --- a/docs/cognito-google-setup.md +++ b/docs/cognito-google-setup.md @@ -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 | |---|---|---|---| @@ -66,39 +66,72 @@ 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` in `terraform/platform/identity/variables.tf` (1 → 2, an in-place update of the domain) | PR | + +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=&redirect_uri=&scope=openid&identity_provider=Google" \ + "https://$DOMAIN/oauth2/authorize?response_type=code&client_id=&redirect_uri=&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. diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index 16c23fe..403eb6c 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -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. +**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. 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. diff --git a/scripts/auth-doctor.py b/scripts/auth-doctor.py index 5c3b470..ad5b495 100755 --- a/scripts/auth-doctor.py +++ b/scripts/auth-doctor.py @@ -12,8 +12,10 @@ the one in cognito-client-id. Reports drift the platform corrected and allowed groups that do not exist (a typo there denies everyone). 3. GET /oauth2/authorize for every callback URL redirects to the sign-in - IdP (not /error?error=redirect_mismatch). For Google, also that the - account chooser is forced (prompt=select_account). + IdP (not /error?error=redirect_mismatch). The probe sends the pool's + COGNITO_EXTRA_AUTH_PARAMS like the app does; for Google, also checks that + the account chooser is forced (prompt=select_account reaches Google, + which needs managed login on the pool domain). 4. GET /logout for every logout URL redirects back to it. Cognito wants logout_uri to match a logout URL exactly (trailing slash included) and otherwise sends the user to its own /login or /error page. @@ -40,7 +42,7 @@ import time import urllib.error import urllib.request -from urllib.parse import parse_qs, urlencode, urlsplit +from urllib.parse import parse_qs, parse_qsl, urlencode, urlsplit HERE = os.path.dirname(os.path.abspath(__file__)) PROJECT = "javabin" @@ -210,9 +212,10 @@ def check_status(report, base, status, current): return True -def probe_authorize(report, domain, client_id, idp, scopes, callback): +def probe_authorize(report, domain, client_id, idp, scopes, callback, extra=""): + """extra: the pool's COGNITO_EXTRA_AUTH_PARAMS query string, sent like an app would.""" query = {"response_type": "code", "client_id": client_id, "redirect_uri": callback, - "scope": " ".join(scopes)} + "scope": " ".join(scopes), **dict(parse_qsl(extra))} if idp and idp != "COGNITO": query["identity_provider"] = idp code, location = http_get(f"https://{domain}/oauth2/authorize?{urlencode(query)}") @@ -235,7 +238,7 @@ def probe_authorize(report, domain, client_id, idp, scopes, callback): if loc.hostname == "accounts.google.com" and "/o/oauth2/" in loc.path: if parse_qs(loc.query).get("prompt") != ["select_account"]: report.warn("authorize", "Google is not asked to show the account chooser (prompt=select_account " - "missing) — see docs/cognito-google-setup.md, managed-login fallback") + "missing: is the pool domain on managed login?) — see docs/cognito-google-setup.md") def probe_logout(report, domain, client_id, logout_url): @@ -321,9 +324,10 @@ def diagnose(app, team=None, name=None, wait=0, tasks=True, sleep=time.sleep): if not domain: report.warn("authorize", f"the {status.get('pool')} pool has no hosted-UI domain; skipping HTTP probes") else: + extra = gen.POOL_EXTRA_AUTH_PARAMS.get(status.get("pool") or auth["pool"], "") for cb in status.get("callback_urls") or auth["callback_urls"]: probe_authorize(report, domain, status["client_id"], status.get("identity_provider"), - status.get("scopes") or auth["scopes"], cb) + status.get("scopes") or auth["scopes"], cb, extra) for lo in status.get("logout_urls") or auth["logout_urls"]: probe_logout(report, domain, status["client_id"], lo) if tasks: diff --git a/scripts/generate-modules.py b/scripts/generate-modules.py index a97bce3..cf0cb85 100755 --- a/scripts/generate-modules.py +++ b/scripts/generate-modules.py @@ -130,6 +130,13 @@ class AppYamlError(ValueError): # Cognito scope names may not contain "/", quotes, backslashes or spaces SCOPE_RE = re.compile(r"^[a-z][a-z0-9._-]{0,39}$") +# 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). +POOL_EXTRA_AUTH_PARAMS = {"internal": "prompt=select_account"} + # Injected when the app calls a peer with scopes / provides scopes PEER_CALLER_AUTH_ENV = ("PEER_AUTH_TOKEN_URL", "PEER_AUTH_CLIENT_ID", "PEER_AUTH_CLIENT_SECRET") PEER_CALLEE_AUTH_ENV = ("PEER_AUTH_ISSUER", "PEER_AUTH_JWKS_URL", "PEER_AUTH_SCOPE_PREFIX") @@ -914,6 +921,8 @@ def src(mod): } if auth["groups"]: auth_env["COGNITO_GROUPS"] = ",".join(auth["groups"]) + if client != "none" and auth["pool"] in POOL_EXTRA_AUTH_PARAMS: + auth_env["COGNITO_EXTRA_AUTH_PARAMS"] = POOL_EXTRA_AUTH_PARAMS[auth["pool"]] auth_secrets = {} if client != "none": auth_secrets["COGNITO_CLIENT_ID"] = Ref("module.auth.client_id_arn") diff --git a/scripts/tests/test_auth_doctor.py b/scripts/tests/test_auth_doctor.py index 2e37ca9..feaa40c 100644 --- a/scripts/tests/test_auth_doctor.py +++ b/scripts/tests/test_auth_doctor.py @@ -74,6 +74,15 @@ def test_missing_account_chooser_warns(self): self.assertFalse(report.failed) self.assertIn(("WARN", "authorize"), self.levels(report)) + def test_probe_sends_the_pools_extra_auth_params(self): + urls = [] + doc.http_get = lambda url: (urls.append(url), self.redirects[ + "authorize" if "/oauth2/authorize" in url else "logout"])[1] + self.run_doctor() + authorize = [u for u in urls if "/oauth2/authorize" in u] + self.assertTrue(authorize) + self.assertTrue(all("prompt=select_account" in u for u in authorize), authorize) + def test_staying_on_hosted_ui_fails_for_federated_idp(self): self.redirects["authorize"] = (302, f"https://{DOMAIN}/login?client_id=abc") self.assertIn(("FAIL", "authorize"), self.levels(self.run_doctor())) diff --git a/scripts/tests/test_generate_modules.py b/scripts/tests/test_generate_modules.py index 83fe778..02cae0c 100644 --- a/scripts/tests/test_generate_modules.py +++ b/scripts/tests/test_generate_modules.py @@ -257,6 +257,19 @@ def test_identity_provider_is_injected_and_aliasable(self): self.assertRegex(main, r"COGNITO_IDENTITY_PROVIDER\s+= module.auth.identity_provider_arn") self.assertRegex(main, r"NUXT_PUBLIC_OIDC_IDENTITY_PROVIDER\s+= module.auth.identity_provider_arn") + def test_internal_clients_get_the_account_chooser_param(self): + app = {**FROSTING, "auth": {"client": "public", + "env": {"COGNITO_EXTRA_AUTH_PARAMS": "NUXT_PUBLIC_OIDC_EXTRA_PARAMS"}}} + main = generate(app)["main.tf"] + self.assertRegex(main, r'COGNITO_EXTRA_AUTH_PARAMS\s+= "prompt=select_account"') + self.assertRegex(main, r'NUXT_PUBLIC_OIDC_EXTRA_PARAMS\s+= "prompt=select_account"') + self.assertIn("COGNITO_EXTRA_AUTH_PARAMS", generate(CUPCAKE)["main.tf"]) # confidential + + def test_external_and_validate_only_apps_get_no_extra_auth_params(self): + self.assertNotIn("COGNITO_EXTRA_AUTH_PARAMS", generate(REAL_CUPCAKE)["main.tf"]) + external = {**FROSTING, "auth": {"pool": "external", "client": "public"}} + self.assertNotIn("COGNITO_EXTRA_AUTH_PARAMS", generate(external)["main.tf"]) + def test_validate_only_app_gets_no_identity_provider(self): self.assertNotIn("COGNITO_IDENTITY_PROVIDER", generate(REAL_CUPCAKE)["main.tf"]) diff --git a/terraform/lambda-src/app_auth_sync/handler.py b/terraform/lambda-src/app_auth_sync/handler.py index b7cc7f1..266657f 100644 --- a/terraform/lambda-src/app_auth_sync/handler.py +++ b/terraform/lambda-src/app_auth_sync/handler.py @@ -39,6 +39,13 @@ with another app's client), and its credentials, IdP and auth-status go next to that request. +Every client also gets a managed-login branding style (Cognito's default +look, UseCognitoProvidedValues), created only when it has none, so a style +someone customised in the console is kept. On a domain with managed login +(ManagedLoginVersion 2) a client without a style has no working sign-in +page; on the classic hosted UI (version 1) the style is unused. The style is +deleted with the client. + The request's allowed groups are written to the auth memberships table as {pk: "client#", allowed_groups: [...]}; the auth-claims trigger refuses tokens for users in none of them. @@ -84,6 +91,8 @@ def _idp_list(value): "external": os.environ.get("EXTERNAL_POOL_DOMAIN", ""), } # Client fields compared for drift reporting (lists compared as sets) +# auth-status "branding": the client's managed-login style +BRANDING_CREATED, BRANDING_EXISTS = "created", "exists" DRIFT_FIELDS = ("AllowedOAuthFlows", "AllowedOAuthFlowsUserPoolClient", "AllowedOAuthScopes", "CallbackURLs", "LogoutURLs", "SupportedIdentityProviders", "ExplicitAuthFlows", "PreventUserExistenceErrors") # Standard-tier parameter limit: 4 KB (bytes, not characters) @@ -245,6 +254,42 @@ def primary_identity_provider(idps): return next((p for p in idps if p != "COGNITO"), "COGNITO") +def ensure_branding(user_pool_id, client_id): + """Give the client a managed-login style with Cognito's defaults unless it + has one. An existing style (default or customised) is never changed. + Returns BRANDING_CREATED or BRANDING_EXISTS.""" + try: + cognito.describe_managed_login_branding_by_client(UserPoolId=user_pool_id, ClientId=client_id) + return BRANDING_EXISTS + except cognito.exceptions.ResourceNotFoundException: + pass + try: + cognito.create_managed_login_branding(UserPoolId=user_pool_id, ClientId=client_id, + UseCognitoProvidedValues=True) + except cognito.exceptions.ManagedLoginBrandingExistsException: + return BRANDING_EXISTS + logger.info("Created managed-login branding for client %s", client_id) + return BRANDING_CREATED + + +def delete_branding(user_pool_id, client_id): + """Delete the client's managed-login style, if it has one.""" + try: + branding = cognito.describe_managed_login_branding_by_client( + UserPoolId=user_pool_id, ClientId=client_id)["ManagedLoginBranding"] + cognito.delete_managed_login_branding(UserPoolId=user_pool_id, + ManagedLoginBrandingId=branding["ManagedLoginBrandingId"]) + except cognito.exceptions.ResourceNotFoundException: + pass + + +def delete_client(user_pool_id, client_id): + """Delete a client with its branding style and allowed-groups record.""" + delete_branding(user_pool_id, client_id) + cognito.delete_user_pool_client(UserPoolId=user_pool_id, ClientId=client_id) + _delete_client_groups(client_id) + + def client_settings(pool, user_pool_id, client_name, req): return { "UserPoolId": user_pool_id, @@ -339,8 +384,7 @@ def sync(team, app, req, reason="event", slot=""): other_id = pool_id(other) stale = find_client(other_id, name) if stale: - cognito.delete_user_pool_client(UserPoolId=other_id, ClientId=stale) - _delete_client_groups(stale) + delete_client(other_id, stale) logger.info("Removed %s from %s pool", name, other) want_secret = req["client_type"] == "confidential" @@ -352,8 +396,7 @@ def sync(team, app, req, reason="event", slot=""): changed = _diff(current, settings) if has_secret != want_secret: # A client's secret setting is immutable; switching type means a new client - cognito.delete_user_pool_client(UserPoolId=upid, ClientId=client_id) - _delete_client_groups(client_id) + delete_client(upid, client_id) logger.info("Replacing %s: client_type changed to %s", name, req["client_type"]) client_id = None if client_id: @@ -386,6 +429,10 @@ def sync(team, app, req, reason="event", slot=""): if id_changed: _put_param(id_param, client_id, False, team, app, slot) _put_client_groups(client_id, req["allowed_groups"], name) + # Last, after the credentials: a failure here must not hold back a + # client that works on the classic hosted UI. It still fails the sync + # (status error, alarm), since on managed login the client cannot sign in. + branding = ensure_branding(upid, client_id) unknown, empty = check_groups(req["allowed_groups"]) return { "status": "synced", @@ -395,6 +442,7 @@ def sync(team, app, req, reason="event", slot=""): "pool_domain": POOL_DOMAINS.get(req["pool"], ""), "identity_providers": idps, "identity_provider": primary_identity_provider(idps), + "branding": branding, "callback_urls": req["callback_urls"], "logout_urls": req["logout_urls"], "scopes": req["scopes"], @@ -500,8 +548,7 @@ def remove(team, app, slot=""): upid = pool_id(pool) client_id = find_client(upid, name) if client_id: - cognito.delete_user_pool_client(UserPoolId=upid, ClientId=client_id) - _delete_client_groups(client_id) + delete_client(upid, client_id) logger.info("Deleted client %s from %s pool", name, pool) ssm.delete_parameters(Names=[*_cred_names(team, app, slot), _idp_name(team, app, slot), _status_name(team, app, slot)]) diff --git a/terraform/lambda-src/tests/test_app_auth_sync.py b/terraform/lambda-src/tests/test_app_auth_sync.py index 6498ad3..774e3e3 100644 --- a/terraform/lambda-src/tests/test_app_auth_sync.py +++ b/terraform/lambda-src/tests/test_app_auth_sync.py @@ -74,6 +74,14 @@ def add_group(self, name, members): self.items[f"group#{name}"] = {"pk": {"S": f"group#{name}"}, "members": {"L": [{"S": m} for m in members]}} +class ResourceNotFoundException(Exception): + pass + + +class ManagedLoginBrandingExistsException(Exception): + pass + + class FakeCognito: POOLS = {"pool-int": "javabin-internal", "pool-ext": "javabin-external"} @@ -81,6 +89,41 @@ def __init__(self, internal_idps=("GoogleSAML",)): self.clients = {} # client_id -> dict self.n = 0 self.internal_idps = list(internal_idps) + # Managed-login styles: branding_id -> {"UserPoolId", "ClientId", "UseCognitoProvidedValues"}. + # Deleting a client leaves its style here, so tests see whether the handler removes it. + self.branding = {} + self.exceptions = types.SimpleNamespace(ResourceNotFoundException=ResourceNotFoundException, + ManagedLoginBrandingExistsException=ManagedLoginBrandingExistsException) + + def _branding_of(self, UserPoolId, ClientId): + return next((bid for bid, b in self.branding.items() + if b["UserPoolId"] == UserPoolId and b["ClientId"] == ClientId), None) + + def describe_managed_login_branding_by_client(self, UserPoolId, ClientId): + bid = self._branding_of(UserPoolId, ClientId) + if bid is None: + raise ResourceNotFoundException(ClientId) + return {"ManagedLoginBranding": {"ManagedLoginBrandingId": bid, "UserPoolId": UserPoolId, + **self.branding[bid]}} + + def create_managed_login_branding(self, UserPoolId, ClientId, UseCognitoProvidedValues): + if self.clients.get(ClientId, {}).get("UserPoolId") != UserPoolId: + raise ResourceNotFoundException(ClientId) + if self._branding_of(UserPoolId, ClientId): + raise ManagedLoginBrandingExistsException(ClientId) + bid = f"style-{ClientId}" + self.branding[bid] = {"UserPoolId": UserPoolId, "ClientId": ClientId, + "UseCognitoProvidedValues": UseCognitoProvidedValues} + return {"ManagedLoginBranding": {"ManagedLoginBrandingId": bid}} + + def delete_managed_login_branding(self, UserPoolId, ManagedLoginBrandingId): + if self.branding.get(ManagedLoginBrandingId, {}).get("UserPoolId") != UserPoolId: + raise ResourceNotFoundException(ManagedLoginBrandingId) + del self.branding[ManagedLoginBrandingId] + + def styles(self): + """client_id -> UseCognitoProvidedValues, for clients that still exist.""" + return {b["ClientId"]: b["UseCognitoProvidedValues"] for b in self.branding.values()} def get_paginator(self, name): if name == "list_user_pools": @@ -603,5 +646,105 @@ def test_other_subpaths_are_not_requests(self): self.assertEqual(self.cognito.clients, {}) +class ManagedLoginBrandingTest(AppAuthSyncTest): + """Managed login (domain version 2) has no sign-in page for a client without a style.""" + SLOT_REQ = LoginSlotTest.SLOT_REQ + STATUS = "/javabin/apps/cupcake/frosting/auth-status" + + def test_new_client_gets_a_default_style(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + cid, _ = self.only_client() + self.assertEqual(self.cognito.styles(), {cid: True}) + self.assertEqual(json.loads(self.ssm.params[self.STATUS])["branding"], "created") + + def test_slot_client_gets_its_own_style(self): + self.put_request(LoginSlotTest.ALB, name=self.SLOT_REQ) + self.h.handler(event(self.SLOT_REQ, "Create"), None) + cid, _ = self.only_client() + self.assertEqual(self.cognito.styles(), {cid: True}) + + def test_resync_keeps_the_existing_style(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + cid, _ = self.only_client() + # Someone customised the style in the branding editor + bid = self.cognito._branding_of("pool-int", cid) + self.cognito.branding[bid]["UseCognitoProvidedValues"] = False + + self.h.handler(event(REQ, "Update"), None) + self.h.handler({"action": "reconcile"}, None) + + self.assertEqual(self.cognito.styles(), {cid: False}) + self.assertEqual(json.loads(self.ssm.params[self.STATUS])["branding"], "exists") + + def test_reconcile_backfills_clients_created_before_branding(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + self.put_request(LoginSlotTest.ALB, name=self.SLOT_REQ) + self.h.handler(event(self.SLOT_REQ, "Create"), None) + self.cognito.branding.clear() + + result = self.h.handler({"action": "reconcile"}, None) + + self.assertEqual(result["error"], []) + self.assertEqual(set(self.cognito.styles()), set(self.cognito.clients)) + + def test_style_created_concurrently_is_not_an_error(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + cid, _ = self.only_client() + self.cognito.describe_managed_login_branding_by_client = lambda **kw: (_ for _ in ()).throw( + ResourceNotFoundException(cid)) + self.assertEqual(self.h.handler(event(REQ, "Update"), None)["status"], "synced") + self.assertEqual(self.cognito.styles(), {cid: True}) + + def test_deleting_the_client_deletes_its_style(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + del self.ssm.params[REQ] + self.h.handler(event(REQ, "Delete"), None) + self.assertEqual(self.cognito.branding, {}) + + def test_orphan_cleanup_deletes_the_style(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + del self.ssm.params[REQ] + self.h.handler({"action": "reconcile"}, None) + self.assertEqual((self.cognito.clients, self.cognito.branding), ({}, {})) + + def test_client_without_a_style_is_deleted_cleanly(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + self.cognito.branding.clear() + del self.ssm.params[REQ] + self.assertEqual(self.h.handler(event(REQ, "Delete"), None)["status"], "deleted") + self.assertEqual(self.cognito.clients, {}) + + def test_pool_switch_and_type_change_take_the_old_style_along(self): + self.put_request() + self.h.handler(event(REQ, "Create"), None) + self.put_request({**REQUEST, "pool": "external"}) + self.h.handler(event(REQ, "Update"), None) + self.put_request({**REQUEST, "pool": "external", "client_type": "public"}) + self.h.handler(event(REQ, "Update"), None) + + cid, client = self.only_client() + self.assertEqual(client["UserPoolId"], "pool-ext") + self.assertEqual(self.cognito.styles(), {cid: True}) + self.assertEqual(len(self.cognito.branding), 1) + + def test_branding_failure_reports_an_error_after_writing_credentials(self): + def denied(**kw): + raise RuntimeError("AccessDenied: cognito-idp:CreateManagedLoginBranding") + self.cognito.create_managed_login_branding = denied + self.put_request() + with self.assertRaises(RuntimeError): + self.h.handler(event(REQ, "Create"), None) + cid, _ = self.only_client() + self.assertEqual(self.ssm.params["/javabin/apps/cupcake/frosting/cognito-client-id"], cid) + self.assertEqual(json.loads(self.ssm.params[self.STATUS])["status"], "error") + + if __name__ == "__main__": unittest.main() diff --git a/terraform/modules/service-routing/main.tf b/terraform/modules/service-routing/main.tf index b884937..571b8ed 100644 --- a/terraform/modules/service-routing/main.tf +++ b/terraform/modules/service-routing/main.tf @@ -78,6 +78,10 @@ resource "aws_lb_listener_rule" "this" { ################################################################################ locals { + # Sent to the internal pool's /oauth2/authorize (apps get the same as + # COGNITO_EXTRA_AUTH_PARAMS from generate-modules.py) + internal_auth_params = { prompt = "select_account" } + login_protected = { for k, v in var.login_rules : k => v if !v.public } login_public = { for k, v in var.login_rules : k => v if v.public } @@ -158,6 +162,12 @@ resource "aws_lb_listener_rule" "login" { # 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 } } diff --git a/terraform/platform/identity/main.tf b/terraform/platform/identity/main.tf index bf2a810..9838e8b 100644 --- a/terraform/platform/identity/main.tf +++ b/terraform/platform/identity/main.tf @@ -102,13 +102,13 @@ resource "aws_cognito_identity_provider" "internal_google" { authorize_scopes = "openid email profile" attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" attributes_url_add_attributes = "true" - # hd= preselects java.no accounts and prompt=select_account always shows - # Google's account chooser. hd= is a hint only: the Internal consent - # screen (and auth-claims) enforce the domain. Cognito keeping query - # parameters here is not documented — verify after apply with the probe - # in docs/cognito-google-setup.md; if they are dropped, set - # google_authorize_url_params = false and use the managed-login fallback. - authorize_url = "https://accounts.google.com/o/oauth2/v2/auth${var.google_authorize_url_params ? "?hd=${var.google_workspace_domain}&prompt=select_account" : ""}" + # Plain URL: Cognito drops query parameters from a Google-type + # authorize_url (describe-identity-provider shows it without them), so + # hd=/prompt= here only produced a perpetual plan diff. The account + # chooser comes from prompt=select_account sent by the app (or the ALB) + # to /oauth2/authorize, which managed login forwards to Google. See + # docs/cognito-google-setup.md. + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" oidc_issuer = "https://accounts.google.com" token_url = "https://www.googleapis.com/oauth2/v4/token" token_request_method = "POST" @@ -122,10 +122,16 @@ resource "aws_cognito_identity_provider" "internal_google" { } } -# Cognito domain — needed for SAML ACS URL and hosted UI +# Cognito domain — needed for SAML ACS URL and hosted UI. +# managed_login_version: 1 = classic hosted UI, 2 = managed login. Only +# managed login forwards prompt=select_account to Google (the account +# chooser), but it has no sign-in page for a client without a branding +# style: app-auth-sync gives every app client one. Changing the version is an +# in-place update of the domain. resource "aws_cognito_user_pool_domain" "internal" { - domain = "${var.project}-internal" - user_pool_id = aws_cognito_user_pool.internal.id + domain = "${var.project}-internal" + user_pool_id = aws_cognito_user_pool.internal.id + managed_login_version = var.internal_managed_login_version } # Cognito groups are managed by the team provisioner (from groups.yaml and diff --git a/terraform/platform/identity/variables.tf b/terraform/platform/identity/variables.tf index ccb8484..f904533 100644 --- a/terraform/platform/identity/variables.tf +++ b/terraform/platform/identity/variables.tf @@ -14,10 +14,19 @@ variable "google_workspace_domain" { default = "java.no" } -variable "google_authorize_url_params" { - description = "Add hd=&prompt=select_account to the internal pool's Google authorize_url (false = plain URL, for the managed-login fallback)" - type = bool - default = true +# 2 only once every internal-pool app client has a managed-login branding +# style (app-auth-sync creates them; check with +# aws cognito-idp describe-managed-login-branding-by-client), or that +# client's sign-in page breaks. +variable "internal_managed_login_version" { + description = "Internal pool domain: 1 = classic hosted UI, 2 = managed login (forwards prompt=select_account to Google)" + type = number + default = 1 + + validation { + condition = contains([1, 2], var.internal_managed_login_version) + error_message = "internal_managed_login_version must be 1 or 2." + } } variable "google_client_id" { diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 715f532..1606a02 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -1623,6 +1623,18 @@ resource "aws_iam_role_policy" "app_auth_sync" { ] Resource = [var.internal_user_pool_arn, var.external_user_pool_arn] }, + { + # One managed-login style per client: a managed-login domain shows no + # sign-in page for a client without one. Never updates a style. + Sid = "ManageAppClientBranding" + Effect = "Allow" + Action = [ + "cognito-idp:CreateManagedLoginBranding", + "cognito-idp:DescribeManagedLoginBrandingByClient", + "cognito-idp:DeleteManagedLoginBranding", + ] + Resource = [var.internal_user_pool_arn, var.external_user_pool_arn] + }, { # ListUserPools does not support resource-level permissions Sid = "FindPools" From 89e780b0241837c377bdf43b27953acc152f66a4 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Thu, 29 Oct 2026 22:46:15 +0100 Subject: [PATCH 2/3] feat(identity): switch the internal pool domain to managed login Managed login forwards prompt=select_account to Google, so heroes get the account chooser. Every app client has a branding style from app-auth-sync (previous change), which managed login needs to show a sign-in page. Co-Authored-By: Claude Opus 5.5 --- docs/cognito-google-setup.md | 2 +- terraform/platform/identity/variables.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cognito-google-setup.md b/docs/cognito-google-setup.md index 8d09b58..7831401 100644 --- a/docs/cognito-google-setup.md +++ b/docs/cognito-google-setup.md @@ -82,7 +82,7 @@ forwards every `prompt` value except `none` to the IdP the user signs in with. S | 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` in `terraform/platform/identity/variables.tf` (1 → 2, an in-place update of the domain) | PR | +| 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. diff --git a/terraform/platform/identity/variables.tf b/terraform/platform/identity/variables.tf index f904533..77ebbb6 100644 --- a/terraform/platform/identity/variables.tf +++ b/terraform/platform/identity/variables.tf @@ -21,7 +21,7 @@ variable "google_workspace_domain" { variable "internal_managed_login_version" { description = "Internal pool domain: 1 = classic hosted UI, 2 = managed login (forwards prompt=select_account to Google)" type = number - default = 1 + default = 2 validation { condition = contains([1, 2], var.internal_managed_login_version) From 2f6a41b94371767be3d978ca98c2c7aa32f045e4 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Thu, 29 Oct 2026 22:48:12 +0100 Subject: [PATCH 3/3] feat(identity): cut the internal pool over from GoogleSAML to Google internal_identity_providers = ["Google"]: every internal app client and auth-claims' FEDERATED_IDENTITY_PROVIDERS move to the Google OIDC provider, which shows Google's account chooser on managed login. Co-Authored-By: Claude Opus 5.5 --- docs/app-yaml-reference.md | 2 +- docs/cognito-google-setup.md | 2 +- docs/lambda-functions.md | 4 ++-- terraform/platform/identity/variables.tf | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 0253da2..19f5269 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. diff --git a/docs/cognito-google-setup.md b/docs/cognito-google-setup.md index 7831401..466ba0d 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`): diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index 403eb6c..2c59940 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. 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. 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/terraform/platform/identity/variables.tf b/terraform/platform/identity/variables.tf index 77ebbb6..aae9a69 100644 --- a/terraform/platform/identity/variables.tf +++ b/terraform/platform/identity/variables.tf @@ -79,7 +79,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")