Skip to content

feat(identity): managed-login branding per app client and prompt=select_account - #170

Merged
Alexanderamiri merged 1 commit into
mainfrom
feat/cognito-managed-login-branding
Oct 29, 2026
Merged

Alexanderamiri merged 1 commit into
mainfrom
feat/cognito-managed-login-branding

Conversation

@Alexanderamiri

Copy link
Copy Markdown
Member

Why

Heroes whose browser is signed in to a personal Google account cannot pick their @java.no account (#157). The Google provider's authorize_url was meant to carry prompt=select_account, but Cognito drops the query string (describe-identity-provider returns the plain URL; every plan shows a perpetual diff), and the classic hosted UI ignores a prompt sent by apps. Managed login (domain ManagedLoginVersion 2) forwards prompt to the IdP, but it has no working sign-in page for an app client without a branding style.

This is PR 1 of 2 and changes no sign-in behaviour: it creates the branding styles, while the domain stays on the classic UI. PR 2 flips the domain.

What

  • app-auth-sync: after the credentials, ensure_branding checks DescribeManagedLoginBrandingByClient and only if there is no style calls CreateManagedLoginBranding(UseCognitoProvidedValues=True). An existing style, default or customised, is never touched. ManagedLoginBrandingExistsException counts as already done. Every client delete path (request gone, pool switch, client_type change, orphan cleanup) now goes through delete_client, which deletes the style first. Reconcile, queued by platform CI after every apply, backfills existing clients, login-N slot clients included. auth-status gains branding: created|exists. A branding failure comes after the credentials are written, so a client that works on the classic UI is not held back, but the sync is still marked error and raises the alarm.
  • IAM: the app-auth-sync role gets cognito-idp:{Create,DescribeManagedLoginBrandingByClient,Delete}ManagedLoginBranding, scoped to the internal and external pool ARNs.
  • identity: the Google authorize_url is plain and google_authorize_url_params is removed (this ends the perpetual diff). New internal_managed_login_version (default 1, validated 1|2) is set on aws_cognito_user_pool_domain.internal. The external pool's domains are unchanged and stay classic. Its clients get styles too, so it could move later with the same one-line change.
  • service-routing: ALB authenticate_oidc rules on the internal pool send authentication_request_extra_params = { prompt = "select_account" }.
  • generate-modules: internal-pool apps with a client get COGNITO_EXTRA_AUTH_PARAMS=prompt=select_account (it can be aliased via auth.env). auth-doctor sends the same parameters in its authorize probe.
  • Docs: in cognito-google-setup.md, the "fallback" is replaced by the actual setup in step 3, with the order, a check loop and the probe. The cutover steps are kept. app-yaml-reference.md gets the new env var and an "Account chooser" section (oidc-client-ts / Spring / Auth.js snippets). lambda-functions.md documents the branding.

Terraform-managed clients: none exist (aws_cognito_user_pool_client is not used anywhere in Terraform), so Terraform creates no branding. aws_cognito_managed_login_branding also only exists in AWS provider 6.x, and the platform pins ~> 5.0.

How verified

  • python3 -m unittest discover -s terraform/lambda-src/tests: 214 OK. The new ManagedLoginBrandingTest covers: create; slot client; a customised style is kept; reconcile backfill; the concurrent-create race; delete, orphan, pool-switch and type-change cleanup; a branding failure after the credentials are written.
  • python3 -m unittest discover -s scripts/tests: 129 OK, with new generate-modules and auth-doctor tests.
  • terraform fmt -check -recursive terraform, plus terraform validate on terraform/platform and terraform/modules/service-routing (aws provider 5.100.0, which has managed_login_version).
  • Live, read-only (eu-central-1):
    • Internal domain javabin-internal: ManagedLoginVersion 1. External: 1.
    • Both pools are on the ESSENTIALS tier.
    • The internal pool has one client, cupcake-cupcake, and no login-N clients. The only /auth request is cupcake's.
    • cupcake-cupcake already has a style, created manually on 2026-10-24 with UseCognitoProvidedValues=false. This PR keeps it.
    • Google authorize_url live is plain .../v2/auth.
    • The classic UI answers /oauth2/authorize?...&prompt=select_account exactly as it does without the parameter (302 to the SAML IdP), so sending prompt before PR 2 is harmless.

Rollout

  1. Merge this PR. The plan should show updates only: the Google IdP authorize_url (the perpetual diff, now gone), the app-auth-sync Lambda code and IAM policy, and no change to aws_cognito_user_pool_domain.internal (it is already version 1). After the apply, the CI reconcile creates the missing styles. Check with the loop in docs/cognito-google-setup.md step 3.
  2. PR 2 (stacked): internal_managed_login_version = 2. This is an in-place update of the domain.
  3. Apps: frosting adds prompt: 'select_account' (separate PR in javaBin/frosting). ALB sign-in apps get the parameter on their next deploy that picks up the module.
  4. The IdP cutover (["Google"]) is a separate draft PR.

🤖 Generated with Claude Code

…ct_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 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 2 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.lambdas.aws_iam_role_policy.app_auth_sync will be updated in-place
  ~ resource "aws_iam_role_policy" "app_auth_sync" {
        id     = "javabin-app-auth-sync:javabin-app-auth-sync"
        name   = "javabin-app-auth-sync"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                    {
                        Action   = [
                            "cognito-idp:CreateUserPoolClient",
                            "cognito-idp:UpdateUserPoolClient",
                            "cognito-idp:DeleteUserPoolClient",
                            "cognito-idp:DescribeUserPoolClient",
                            "cognito-idp:ListUserPoolClients",
                            "cognito-idp:ListIdentityProviders",
                        ]
                        Effect   = "Allow"
                        Resource = [
                            "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_Icikv3dtD",
                            "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_gdFOsE4EM",
                        ]
                        Sid      = "ManageAppClients"
                    },
                  + {
                      + Action   = [
                          + "cognito-idp:CreateManagedLoginBranding",
                          + "cognito-idp:DescribeManagedLoginBrandingByClient",
                          + "cognito-idp:DeleteManagedLoginBranding",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_Icikv3dtD",
                          + "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_gdFOsE4EM",
                        ]
                      + Sid      = "ManageAppClientBranding"
                    },
                    {
                        Action   = "cognito-idp:ListUserPools"
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "FindPools"
                    },
                    # (3 unchanged elements hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.lambdas.aws_lambda_function.app_auth_sync will be updated in-place
  ~ resource "aws_lambda_function" "app_auth_sync" {
        id                             = "javabin-app-auth-sync"
      ~ last_modified                  = "2026-10-26T14:01:56.000+0000" -> (known after apply)
      ~ source_code_hash               = "GvhzSxolQGY8f4/fMAxElLFmKIXk8607SS3v89Em3x4=" -> "HKStd1VAWlTHRUFr+guun1HqV1RHui7nc1qw1cRONzY="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟡 MEDIUM

In-place update of the app-auth-sync Lambda's code and its IAM role policy, adding Cognito managed login branding permissions on the two existing user pools; no creates or destroys.

@Alexanderamiri
Alexanderamiri merged commit 140f2ce into main Oct 29, 2026
4 checks passed
@Alexanderamiri
Alexanderamiri deleted the feat/cognito-managed-login-branding branch October 29, 2026 22:02
Alexanderamiri added a commit that referenced this pull request Oct 29, 2026
## Why

The classic hosted UI ignores `prompt=select_account`. Managed login
forwards it to Google, so heroes signed in to a personal Google account
get the account chooser instead of a dead end (#157).
Stacked on #170.

## What

`internal_managed_login_version` default 1 → 2
(`aws_cognito_user_pool_domain.internal`). The doc table in
`cognito-google-setup.md` is updated to match. The external pool stays
on the classic UI.

## How verified

- `terraform fmt -check -recursive terraform`
- The plan must show **one in-place update** of
`module.identity.aws_cognito_user_pool_domain.internal`
(`managed_login_version: 1 -> 2`), with no pool, domain or client
replacement.

## Rollout

**Merge only after #170 is applied** and its reconcile has run. Before
merging, check that every internal client has a style (loop in
`docs/cognito-google-setup.md` step 3). On a managed-login domain, a
client without a style has no sign-in page. Live today, the only
internal client, `cupcake-cupcake`, already has a style (created
manually on 2026-10-24).

After apply:
- Open `https://cupcake.javazone.no/` and sign in. Expect the redirect
to the IdP (GoogleSAML until the cutover), not a Cognito error page.
- `python3 scripts/auth-doctor.py --app-yaml <frosting>/app.yaml`
- Users may have to sign in again once (a new Cognito session).

Rollback: set the variable back to 1. This is also an in-place update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



Replaces #171, which GitHub closed when its base branch (#170) was
deleted on merge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Alexanderamiri added a commit that referenced this pull request Oct 30, 2026
…175)

Replaces #172 (auto-closed when its base branch was deleted). Rebased
onto main: #170, #173 (managed login) and #174 (IdP probe client) are
merged and applied.

## Why

GoogleSAML has no account chooser. A browser signed in to a personal
Google account hits a dead-end `403 app_not_configured_for_user`
(#157). The Google provider on managed login can show
Google's account chooser, but only if `prompt=select_account` reaches
Google. **DRAFT: do not merge until the checklist below is done.**

## Probe result (2026-10-30, `platform-idp-probe`, client
`75ejr2cdi133ictvip1mc32nr3`, lists only `Google`)

Each request got a 302 straight to
`accounts.google.com/o/oauth2/v2/auth`. Results were the same on
repeated runs:

| `/oauth2/authorize` extra params | `prompt` in Google URL | `hd` in
Google URL |
|---|---|---|
| `prompt=select_account` | **absent** | absent |
| `prompt=select_account&identity_provider=Google` |
`prompt=select_account` | absent |
| `identity_provider=Google` | absent | absent |
| none | absent | absent |
| `prompt=select_account&identity_provider=Google&hd=java.no` |
`prompt=select_account` | absent |

Managed login forwards `prompt` only when the request names the IdP. On
the single-IdP auto-redirect it drops it. `hd` is never forwarded.
Domain enforcement relies on the Internal consent screen and
auth-claims, as designed. The generic OIDC IdP fallback is **not
needed**: the Google-type IdP does get `prompt`, the request just has to
include `identity_provider`. So this PR adds that fix instead of the
fallback.

## What

- `internal_identity_providers` default changes from `["GoogleSAML"]` to
`["Google"]`. One list feeds every internal app client (app-auth-sync)
and auth-claims' `FEDERATED_IDENTITY_PROVIDERS`.
- **ALB sign-in** (`terraform/modules/service-routing`): internal-pool
rules now send `identity_provider=<client's IdP>` next to
`prompt=select_account`. The IdP is read from the client's
`cognito-client-identity-provider` parameter, which app-auth-sync
writes, so it is never hardcoded and follows the cutover. Adds a
`identity_provider_parameter_name` output to `cognito-app-client`.
- **Apps:** `docs/app-yaml-reference.md` "Account chooser" now says to
send `identity_provider=$COGNITO_IDENTITY_PROVIDER` together with
`COGNITO_EXTRA_AUTH_PARAMS`. The generate-modules comment and
`docs/cognito-google-setup.md` step 3 record the probe result.

## How verified

- `python3 -m unittest discover -s scripts/tests`: OK. `python3 -m
unittest discover -s terraform/lambda-src/tests`: 219 OK.
- `terraform fmt -check -recursive terraform` and `terraform validate`
on `service-routing`.
- Expected platform plan: the app-auth-sync and auth-claims Lambda
environments are updated in place. No Cognito pool, domain or client is
replaced (Terraform manages no clients). After apply, the CI reconcile
switches every internal client to `Google` and rewrites
`cognito-client-identity-provider`, which restarts each auth app
(app-secret-rollout).
- The service-routing change reaches app repos on their next deploy.
Before the cutover it sends `identity_provider=GoogleSAML`, which
matches today's auto-redirect.

## Rollout: pre-cutover checklist

- [x] #170 applied, every internal client has a branding style
(app-auth-sync)
- [x] #173 applied: the internal domain is on managed login
- [ ] Internal-pool lockdown (#166) is live: `REQUIRED_EMAIL_DOMAIN`, no
self sign-up, no `COGNITO` on internal clients
- [x]
`/javabin/platform/google-oauth/internal/{client-id,client-secret}`
exist (the `Google` IdP is in the pool)
- [x] Probe passes when `identity_provider` is sent (table above)
- [ ] Apps that use ALB sign-in redeploy with this service-routing
version
- [ ] frosting and any other SPA/backend send `prompt=select_account`
**and** `identity_provider=$COGNITO_IDENTITY_PROVIDER`. `prompt` alone
is dropped
- [ ] Apps keyed by `sub` (check cupcake/frosting) can handle a new
`sub` per user. Google users are `Google_<sub>` with a new `sub`. The
platform keys on email

## Rollout: after merge

1. `python3 scripts/auth-doctor.py --app-yaml <app>/app.yaml` for each
auth app. Expect a redirect to `accounts.google.com` with
`prompt=select_account`. auth-doctor sends `identity_provider` like a
correct app, so also check that the app itself sends it.
2. Sign in once with a personal Gmail account active in the browser.
Google must show the account chooser.
3. A week later, delete the 4 `GoogleSAML_*` users, the `GoogleSAML`
provider and the Google Admin SAML app (`docs/cognito-google-setup.md`,
"Decommission").

Rollback: revert to `["GoogleSAML"]` (GoogleSAML still exists until
decommission).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant