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

Rules and priorities:

Expand Down Expand Up @@ -418,6 +419,17 @@ python3 ../platform/scripts/auth-doctor.py --app-yaml app.yaml # add --json fo
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | `confidential` only |
| `COGNITO_IDENTITY_PROVIDER` | SSM (ECS task `secrets`) | `confidential` and `public` only. The IdP the client signs in through (`GoogleSAML`, `Google`, or `COGNITO`), for apps that send `identity_provider` themselves. Changes at the IdP cutover, which restarts the service |
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Only when `groups` is set |
| `COGNITO_EXTRA_AUTH_PARAMS` | Fixed per pool, URL query string | Internal pool, `confidential` and `public` only: `prompt=select_account`. Add it to your `/oauth2/authorize` request (see below) |

#### Account chooser (internal pool)

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

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

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

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

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

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

| Integration | Purpose | Credential | Stored in |
|---|---|---|---|
Expand Down Expand Up @@ -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=<id>&redirect_uri=<callback>&scope=openid&identity_provider=Google" \
"https://$DOMAIN/oauth2/authorize?response_type=code&client_id=<id>&redirect_uri=<callback>&scope=openid&identity_provider=Google&prompt=select_account" \
| grep -q 'prompt=select_account' && echo OK || echo "prompt dropped"
```

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

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

## 4. Cutover: GoogleSAML → Google

Prerequisites:

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

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

**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.

Expand Down
18 changes: 11 additions & 7 deletions scripts/auth-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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)}")
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions scripts/generate-modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions scripts/tests/test_auth_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
Loading
Loading