Repository navigation
fix(identity): lock down the internal Cognito pool - #166
Merged
Merged
Conversation
- Disable self sign-up on the internal pool (allow_admin_create_user_only). - app-auth-sync: give each pool a fixed IdP list from Terraform instead of every pool IdP plus COGNITO; skip and log when a configured IdP is missing. - auth-claims: on the internal pool, refuse tokens unless the email is @java.no and the user is federated (GoogleSAML) or email_verified. Part of #157 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Terraform Plan🚧 Changes detected — Plan: 0 to add, 3 to change, 0 to destroy. Plan outputLLM ReviewRisk: 🟡 MEDIUM Plan updates 3 existing resources in place (0 add, 0 destroy): a Cognito user pool setting change that stops self sign-up, and two Lambda code and environment updates for federated identity provider configuration.
|
…on skipped syncs Review follow-up for the internal pool lockdown: - app-auth-sync rejects aws.cognito.signin.user.admin for pool=internal, so a signed-in user cannot rewrite the email auth-claims trusts for group lookup - alarm javabin-app-auth-sync-skipped (log metric filter) posts to javabin-alerts when a request is skipped for a missing IdP; the alerts topic policy now lets CloudWatch alarms in this account publish - document that every internal_identity_providers entry vouches for email - auth-claims masks the email in the group denial log too; tidy imports Part of #157 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Terraform Plan🚧 Changes detected — Plan: 2 to add, 4 to change, 0 to destroy. Plan outputLLM ReviewRisk: 🟡 MEDIUM Plan adds two CloudWatch resources (metric filter and alarm) for app-auth-sync skipped requests and updates the Cognito user pool, two Lambda functions, and an SNS topic policy; nothing is destroyed, but enabling admin-only user creation and the new Lambda environment variables change authentication behaviour and should be confirmed before apply.
|
Alexanderamiri
added a commit
that referenced
this pull request
Oct 24, 2026
…th doctor Builds on the internal-pool lockdown (#166) and reuses its per-pool IdP list (internal_identity_providers, INTERNAL_IDENTITY_PROVIDERS, identity_providers(pool, upid), PoolMisconfigured), so one list drives both the app clients and auth-claims' trusted IdPs. - identity: Google OIDC IdP on the internal pool, created only once its OAuth client is in SSM (/javabin/platform/google-oauth/internal/*); authorize_url forces the account chooser (hd=java.no&prompt=select_account), email_verified mapped. Precondition: "Google" in internal_identity_providers needs the credentials and google_workspace_domain. - auth-claims: Google users also need email_verified=true (EMAIL_VERIFIED_IDENTITY_PROVIDERS, default Google). - app-auth-sync: write cognito-client-* only on change, add cognito-client-identity-provider, publish team-readable auth-status (status, client, client_id_changed_at, IdPs, drift corrected, unknown groups; always valid JSON under 4096 bytes); reject URL fragments; reconcile keeps going past a failing app and returns normally (logged for the skipped alarm) instead of raising into async retries. - app-secret-rollout: restart on cognito-client-id / -identity-provider changes; ignore auth-status and cognito-client-secret (a new secret always comes with a new ID). - generate-modules: validate callback/logout hosts (routing.host or auth.extra_hosts), https except localhost, no fragments; reject client: none with URLs/scopes; inject COGNITO_IDENTITY_PROVIDER. - scripts/auth-doctor.py, run non-blocking after app apply (FAIL on a non-JSON status, short wait when auth-status is missing, task check is a WARN against client_id_changed_at for PRIMARY tasks only); platform CI queues a reconcile after apply. - Docs: GCP setup with the correct redirect URI, cutover plan with the lockdown as a hard prerequisite; team CI roles can still write platform-managed parameters (documented); SAML doc marked superseded. Part of #157 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Alexanderamiri
added a commit
that referenced
this pull request
Oct 24, 2026
…th doctor Builds on the internal-pool lockdown (#166) and reuses its per-pool IdP list (internal_identity_providers, INTERNAL_IDENTITY_PROVIDERS, identity_providers(pool, upid), PoolMisconfigured), so one list drives both the app clients and auth-claims' trusted IdPs. - identity: Google OIDC IdP on the internal pool, created only once its OAuth client is in SSM (/javabin/platform/google-oauth/internal/*); authorize_url forces the account chooser (hd=java.no&prompt=select_account), email_verified mapped. Precondition: "Google" in internal_identity_providers needs the credentials and google_workspace_domain. - auth-claims: Google users also need email_verified=true (EMAIL_VERIFIED_IDENTITY_PROVIDERS, default Google). - app-auth-sync: write cognito-client-* only on change, add cognito-client-identity-provider, publish team-readable auth-status (status, client, client_id_changed_at, IdPs, drift corrected, unknown groups; always valid JSON under 4096 bytes); reject URL fragments; reconcile keeps going past a failing app and returns normally (logged for the skipped alarm) instead of raising into async retries. - app-secret-rollout: restart on cognito-client-id / -identity-provider changes; ignore auth-status and cognito-client-secret (a new secret always comes with a new ID). - generate-modules: validate callback/logout hosts (routing.host or auth.extra_hosts), https except localhost, no fragments; reject client: none with URLs/scopes; inject COGNITO_IDENTITY_PROVIDER. - scripts/auth-doctor.py, run non-blocking after app apply (FAIL on a non-JSON status, short wait when auth-status is missing, task check is a WARN against client_id_changed_at for PRIMARY tasks only); platform CI queues a reconcile after apply. - Docs: GCP setup with the correct redirect URI, cutover plan with the lockdown as a hard prerequisite; team CI roles can still write platform-managed parameters (documented); SAML doc marked superseded. Part of #157 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Alexanderamiri
added a commit
that referenced
this pull request
Oct 24, 2026
…th doctor (#167) ## Why Part of #157. Internal-pool sign-in goes through the `GoogleSAML` app, which always answers for the browser's active Google account. A personal Gmail gets a dead-end `403 app_not_configured_for_user` page with no account chooser. The classic hosted UI drops `prompt=`, so Cognito can't ask SAML for one either. The fix is a Google OIDC IdP, with an Internal-consent OAuth client and `prompt=select_account`. The investigation also found that teams can't see why sign-in fails, and can't recover: - invalid requests and console drift are visible only in CloudWatch; - a replaced client leaves running tasks on a deleted client ID; - callback typos show up only as `redirect_mismatch` in the browser; - an `auth.groups` typo denies everyone. **Merge #166 (internal-pool lockdown) first.** This branch is rebased on it and reuses its per-pool IdP list, so until #166 merges this PR's diff and plan also contain #166's two commits. After #166 lands, GitHub shows only the last commit. This PR is safe to merge before the Google credentials exist. Every Google behaviour change is gated, and with #166 the internal default is `["GoogleSAML"]` (what cupcake's client has live today). ## What **identity (Terraform)** - `aws_cognito_identity_provider.internal_google` is now gated on SSM `/javabin/platform/google-oauth/internal/{client-id,client-secret}`, read with `aws_ssm_parameters_by_path`, instead of a tfvar. Its count stays 0 until both exist. - `authorize_scopes = "openid email profile"`, and mapping `email`, `email_verified`, `name`, `username=sub`. - `authorize_url = …/v2/auth?hd=java.no&prompt=select_account`. You can turn this off with `google_authorize_url_params = false` for the managed-login fallback. - Uses #166's per-pool lists `internal_identity_providers` (default `["GoogleSAML"]`, COGNITO refused) and `external_identity_providers` (`["COGNITO"]`), env `INTERNAL_IDENTITY_PROVIDERS`/`EXTERNAL_IDENTITY_PROVIDERS`. The same internal list is auth-claims' `FEDERATED_IDENTITY_PROVIDERS`, so the cutover changes clients and the trust list together. - Adding the Google IdP changes no client until this list names it. - An output precondition fails the plan if `Google` is listed without the SSM credentials or with an empty `google_workspace_domain` (auth-claims' `REQUIRED_EMAIL_DOMAIN`). - auth-claims: IdPs in `EMAIL_VERIFIED_IDENTITY_PROVIDERS` (default `Google`) are not trusted on the IdP name alone; their users also need `email_verified=true`, mapped from Google's claim. - The external pool keeps its `google_client_id` tfvar path. It is unchanged and still empty. **app-auth-sync** - `cognito-client-id`, `-secret` and the new `-identity-provider` are written **only when the value changes**. - The secret is not decrypted. It changes only with a new client ID. - The client ID is written last, so restarted tasks find the matching secret. - Writes `/javabin/apps/{team}/{app}/auth-status` (String JSON, always valid and under 4096 bytes: list fields are dropped, never sliced). Fields: `status` (synced/invalid/error), `error`, `client_id`, `client_id_changed_at` (carried over between syncs), `identity_providers`, `pool_domain`, URLs, `drift_corrected` (fields a reconcile reverted; also logged as `WARN drift`), `unknown_groups`/`empty_groups` (checked against `group#` items in the memberships table), `request_version`, `synced_at`. - A declared IdP missing from the pool is #166's `PoolMisconfigured` skip: status `error` in auth-status, the client is left as it is. - Reconcile no longer stops at a failing app, and returns normally instead of raising (an async invoke would retry the whole run twice). The failure is logged as `Sync failed for …`, which #166's `javabin-app-auth-sync-skipped` metric filter now also counts. - Callback/logout URLs with a `#fragment` are rejected by the Lambda too. - IAM: `dynamodb:GetItem` on the memberships table, plus write access to `*/cognito-client-identity-provider` and `*/auth-status`. **Team access to auth-status** - Teams read it through their existing `/javabin/apps/{team}/*` read grant. - Writes are denied for `auth-status` in the team-provisioner permission-set policy (CI-applied), and in `terraform/org/identity-center.tf` (human-applied; see user actions). **app-secret-rollout** - `cognito-client-id` and `cognito-client-identity-provider` are no longer ignored. A replaced client (case 9/15) or an IdP cutover now restarts the service. - `auth-status` and `cognito-client-secret` are ignored: auth-status is rewritten on every sync, and a new secret always comes with a new ID written right after it, so restarting on the secret would deploy once with the old ID. - There is no restart loop, because unchanged values are never rewritten. **generate-modules.py** - Callback/logout URLs must: - use `https`, except `http://localhost` / `127.0.0.1`; - have no `#fragment`; - have a host equal to `routing.host` or listed in the new optional `auth.extra_hosts`. - `client: none` combined with `callback_urls`, `logout_urls`, `scopes` or `extra_hosts` is rejected; these were silently ignored before. - `COGNITO_IDENTITY_PROVIDER` is injected (an ECS secret from `module.auth.identity_provider_arn`) and can be aliased through `auth.env`. - Auth resolution is factored into `resolve_auth()`, which the doctor reuses. - Both live apps (frosting, cupcake-backend) still generate. Tests use their verbatim app.yaml. **cognito-app-client module** - New outputs `identity_provider_arn` and `auth_status_parameter_name`. **scripts/auth-doctor.py** - Uses team credentials only: app.yaml, the app's own SSM parameters, and unauthenticated HTTP. - Checks: - the request matches app.yaml; - auth-status is synced for the current request version, and its client ID matches `cognito-client-id`; - drift and unknown/empty groups are reported; - `/oauth2/authorize` for every callback URL redirects to the IdP, not `/error?error=redirect_mismatch` (it warns if Google isn't given `prompt=select_account`); - `/logout` for every logout URL redirects back (a mismatch lands on the hosted UI `/login`, verified live); - best effort, WARN only: the service's PRIMARY-deployment tasks started after `client_id_changed_at` (skipped until auth-status records one; `cognito-client-id`'s LastModifiedDate is useless because the old code rewrote it on every reconcile). - A non-JSON auth-status is a FAIL, not a traceback. When auth-status is missing altogether, the doctor waits at most 30 s instead of the full `--wait`. - `--json` is the hook for a future `javabin auth doctor`. javabin-cli is not changed here. - `tf-apply.yml` gets a non-blocking `Auth doctor` step (`continue-on-error`, `--wait 180`, skipped for apps without auth). **platform-ci** - After apply, `scripts/reconcile-app-auth.sh` queues an async reconcile. This writes the new `cognito-client-identity-provider` and `auth-status` parameters right away, so an app redeploy that picks up the new `COGNITO_IDENTITY_PROVIDER` secret doesn't hit `ParameterNotFound`. It is non-blocking, and the 03:30 reconcile is the backstop. **Docs** - `docs/cognito-google-setup.md` is rewritten: - exact GCP steps (Internal consent screen, a separate Web OAuth client, redirect `https://javabin-internal.auth.eu-central-1.amazoncognito.com/oauth2/idpresponse`; the old doc listed a nonexistent `auth-internal.javazone.no`); - SSM commands; - the post-apply curl probe and the managed-login fallback; - cutover and user plan. - `docs/cognito-saml-setup.md` is marked superseded. It also noted that its groups mapping never existed. - `docs/app-yaml-reference.md`: IdP list, URL rules and `extra_hosts`, the `/logout` exact-match rule, group denial (`error_description`), `COGNITO_IDENTITY_PROVIDER`, auth-status and the doctor. - `docs/lambda-functions.md`, `docs/reusable-modules.md` and `CLAUDE.md` are updated. ## How verified - `python3 -m unittest discover -s terraform/lambda-src/tests` passes (111, including #166's), and `-s scripts/tests` passes (67, including the fmt check of generated TF). New tests cover: - no-op syncs write only auth-status; - secret written before ID on replacement; - status content: invalid, error, drift vs request change, unknown/empty groups; - declared IdP list, cutover switching the IdP param without touching the client ID, a missing IdP failing cleanly; - reconcile continuing past a failing app; - rollout restarting on `cognito-client-*`; - URL validation; - doctor scenarios, including non-JSON status, the short wait for a missing status, and draining/stale tasks; - auth-status size limit (bytes, valid JSON), `client_id_changed_at` carry-over, fragments rejected; - auth-claims refusing Google users without `email_verified=true`. - `terraform fmt -recursive -check` and `terraform validate` (platform root) pass. - Live, read-only: the doctor against frosting's app.yaml. - Request check passes. auth-status doesn't exist yet, as expected before deploy. - Probes against the live `cupcake-cupcake` client: callback → GoogleSAML ✔, `/nope` → `redirect_mismatch` ✔, logout exact ✔, logout without trailing slash → hosted UI `/login` (this is why the logout check looks for "redirects back"). - Live pool state checked: 4 users, all `GoogleSAML_*` external-provider users, no native users. `/javabin/apps/*` has only cupcake's `auth` and `cognito-client-id`. ## Rollout & user actions Merging (after #166) changes nothing about Google sign-in: - The Google IdP isn't created without SSM credentials. - The internal IdP list default is #166's `["GoogleSAML"]`, which equals cupcake's live client (`SupportedIdentityProviders=[GoogleSAML]`). The post-apply reconcile therefore does not put COGNITO back; on main without #166, the old 03:30 reconcile would. - On the first reconcile, cupcake gets `cognito-client-identity-provider = GoogleSAML` (Create, so no restart) and an `auth-status`. Expected plan: the `aws_ssm_parameters_by_path` read, and in-place updates to the app-auth-sync role policy, function env/code, app-secret-rollout code and team-provisioner code. No destroys. User actions: 1. **Human apply `terraform/org`** (identity-center.tf). It adds `auth-status` to the `javabin-developer` permission set's platform-managed deny. Per-team permission sets get it from team-provisioner on its next run. 2. **GCP (when ready):** create the Internal-consent OAuth client and store it in SSM, following `docs/cognito-google-setup.md` §1–2. Then re-run platform CI on main. The plan creates the `Google` IdP, and clients are untouched. 3. **Verify:** run the §3 curl probe (or the doctor after cutover) to confirm Cognito keeps `prompt=select_account`. If it doesn't, use the documented managed-login fallback. 4. **Cutover (separate PR):** `internal_identity_providers = ["Google"]`. #166 (server-side domain check, no self sign-up) is a hard prerequisite, which stacking on it guarantees. - Users get a new `sub`. Platform memberships and groups are keyed by email, so nothing to migrate there; cupcake should check for `sub`-keyed data. - Delete the 4 `GoogleSAML_*` users and the `GoogleSAML` IdP a week later, and turn off the Google Admin SAML app. ## Interactions - **#166 (lockdown):** this PR is stacked on it and reuses its variable, output, env names and `identity_providers(pool, upid)`. It adds to #166's code: the Google precondition on `internal_identity_providers`, `EMAIL_VERIFIED_IDENTITY_PROVIDERS` in auth-claims, and a second pattern on the skipped-sync metric filter. If #166 is squash-merged, I rebase this branch onto main with `--onto`. - **#158 / `feat/alb-login`:** both change `app_auth_sync` (`client_settings`, `sync`, `_put_param`, `reconcile`) and `generate-modules.py` auth handling. Expect textual conflicts; the slot threading composes with this. - Login slots should write `auth-status` and `cognito-client-identity-provider` next to the slot request (`…/login-N/`). The rollout regex already ignores slot params. - service-routing's hardcoded `internal_identity_provider = "GoogleSAML"` should go away: with a single IdP per client it is unnecessary, and after cutover it points at a deleted IdP. Otherwise read the slot's `cognito-client-identity-provider`. - **#156:** also edits `app_secret_rollout/handler.py`, a small conflict. ## Follow-ups - The developer boundary (team CI roles, gate-vended apply credentials) doesn't deny writing `auth-status`/`cognito-client-*`. It is at 5,946/6,144 bytes and #156 adds to it, so I left it alone; the limitation is now documented in `docs/lambda-functions.md` and `docs/app-yaml-reference.md`. The impact stays inside the team's own app: a forged status, or one extra restart before the next sync puts a credential back. A Deny in the team CI role policies is the follow-up if we want it enforced. - Drop `ALLOW_USER_SRP_AUTH` from internal clients. With #166 there is no COGNITO and no self sign-up, so it is unused there, but changing client auth flows is #166-adjacent scope. - `javabin auth doctor` in javabin-cli, wrapping `scripts/auth-doctor.py --json`. - Doctor check that the task definition contains every `auth.env` alias; canary-client probe in platform CI after identity applies. - `auth.accept_clients` for `client: none` backends (case 13). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Part of #157. The internal pool (
javabin-internal,eu-central-1_Icikv3dtD) is open to the internet today:AllowAdminCreateUserOnlyisfalse, so self sign-up is on.COGNITOon every client, so the hosted UI shows a sign-up link.Together, anyone can create a user in the internal pool with their own Gmail address. An app with
auth: internaland nogroupsaccepts that user. Nothing checks the email domain on the server side.The
COGNITOentry also puts a password form in front of Google. The frosting client was hand-edited in the console to[GoogleSAML]to remove the form. The 03:30 UTC reconcile undoes that edit.What
identity/main.tfadmin_create_user_config { allow_admin_create_user_only = true }.auth-claimsfunction gets two new env vars:REQUIRED_EMAIL_DOMAIN = var.google_workspace_domain(java.no);FEDERATED_IDENTITY_PROVIDERS, set from the internal IdP list.New variables
internal_identity_providers(default["GoogleSAML"]) andexternal_identity_providers(default["COGNITO"]).INTERNAL_IDENTITY_PROVIDERS/EXTERNAL_IDENTITY_PROVIDERS.COGNITOand must not be empty.app-auth-sync
ERRORlog andstatus: error(reconcile lists it undererror). Nothing is created, moved or wiped, and the existing client keeps working.auth-claims: when
REQUIRED_EMAIL_DOMAINis set, a token is refused unless both of these hold:@java.no(case-insensitive);identitiesattribute, or the<Provider>_username), or hasemail_verified=true.The user sees "Sign in with your @java.no account." Logs mask the address as
e***@java.no.Review follow-ups
aws.cognito.signin.user.adminforpool: internal(statusinvalid, client not created or changed). That scope would let a signed-in user callUpdateUserAttributesand point their email at another@java.nouser, whose registry groups auth-claims would then grant. No live request uses it (only cupcake, withopenid email profile). The external pool still allows it.aws_cloudwatch_log_metric_filter+aws_cloudwatch_metric_alarmjavabin-app-auth-sync-skippedonclient left unchangedin/aws/lambda/javabin-app-auth-sync, posting tojavabin-alerts(Slack via slack-alert). Thejavabin-alertstopic policy gets anAllowCloudWatchAlarmsPublishstatement scoped to this account's alarms.internal_identity_providersdocuments that each entry is trusted to vouch for email, so it must release only Workspace accounts (Google OIDC needs an Internal-consent client, or anemail_verified/hdcheck first).Docs:
docs/lambda-functions.md(both functions) and thedocs/app-yaml-reference.mdauth section, which covers no password form or sign-up, the@java.norule, and how a denial arrives aserror_description.How verified
python3 -m unittest discover -s terraform/lambda-src/tests(79 OK) andscripts/tests(43 OK). New tests cover:email_verified=false, lookalike domains, native users with and without a verified email, other IdPs, and a pool without the rule.terraform fmt -check -recursiveandterraform validatepass.GoogleSAML_…,@java.no,EXTERNAL_PROVIDER,email_verified=false, identities providerGoogleSAML. All 4 pass through the federated-IdP branch. This exact shape is a unit test./javabin/apps/cupcake/cupcake/auth(frosting, public client, groups[helter]). After this change its client gets[GoogleSAML], the same as the console edit, so the reconcile no longer reverts it. cupcake-backend isclient: noneand is unaffected.GoogleSAML, so the configured list resolves. The external pool keeps["COGNITO"], which is what it gets today (it has no IdPs).aws_cognito_user_pool.internal(admin_create_user_configis not ForceNew),aws_lambda_function.auth_claims(env) andaws_lambda_function.app_auth_sync(env + code),aws_sns_topic_policy.alerts(new statement);Rollout & user actions
[GoogleSAML]. To apply it right away, runaws lambda invoke --function-name javabin-app-auth-sync --payload '{"action":"reconcile"}' --cli-binary-format raw-in-base64-out /dev/stdout.Follow-ups
feat/alb-login). That PR edits the same functions in app-auth-sync (client_settings,sync,reconcile) and addsauth_claims_externalfrom the same handler. The external function doesn't setREQUIRED_EMAIL_DOMAIN, so it skips the domain check, which is intended.client_settings()now takespoolfirst.service-routing's hardcodedinternal_identity_provider = "GoogleSAML"can be dropped once clients have a single IdP. The hosted UI already redirects.deletion_protection = "ACTIVE"to both pools (raised in review here too; left to fix(platform): harden ALB, Cognito pools, subnets and bucket TLS #165 to avoid a duplicate). The change is a different attribute, so expect a trivial text merge at most.user_attribute_update_settings { attributes_require_verification_before_update = ["email"] }on the internal pool. Blocking the user-admin scope closes the reported path; the pool setting would also affect how SAML attribute mapping updates email, which can't be checked without applying, so it is left for a separate, tested change.403 app_not_configured_for_userwhen another Google account is active #157 next step: switching to Google OIDC.internal_identity_providers = ["Google"]once the IdP exists.email_verifiedand drop Google from the trusted list.GoogleSAMLunder Terraform with animportblock, or remove it after cutover.🤖 Generated with Claude Code