Skip to content

feat(routing): require sign-in on app.yaml paths at the load balancer - #158

Open
Alexanderamiri wants to merge 2 commits into
mainfrom
feat/alb-login
Open

Alexanderamiri wants to merge 2 commits into
mainfrom
feat/alb-login

Conversation

@Alexanderamiri

@Alexanderamiri Alexanderamiri commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Why

App developers should be able to put simple apps and static pages behind a Cognito login with a few lines of app.yaml, and choose per path which groups may see them. This must work for the internal pool (Google Workspace SAML) and the external pool (attendees and other public users). Until now the only option was auth:, where the app has to do OIDC itself.

What

New routing.login in app.yaml:

routing:
  host: wiki.java.no
  priority: 120
  login:
    - paths: ["/admin/*"]
      groups: [styret]
    - paths: ["/assets/*"]
      public: true
    - groups: [helter]      # paths defaults to ["/*"]; pool defaults to internal

Shorthands: login: internal / login: external, or a single mapping.

  • Generator (scripts/generate-modules.py): validates the new field (forms, 1-10 rules, 1-4 paths per rule, ALB path characters, no duplicate paths, nothing after a /* rule, public cannot be combined with pool/groups, room below routing.priority, group names). It emits login_rules (p1..pN) at priorities priority-1, priority-2, ... and warns when no rule covers /*, because path patterns are case-sensitive. Apps without login generate exactly the same Terraform as before.

  • service-routing: for each protected rule:

    • a Cognito client {team}-{app}.login-N (nested cognito-app-client, slot login-N);
    • a 60 s time_sleep while app-auth-sync creates it (re-armed only when the request changes);
    • reads of the client id and secret from the team's SSM path;
    • an authenticate-oidc + forward listener rule on host and paths. The internal pool goes straight to GoogleSAML. Sessions last 8 h.

    Public rules only forward. The host catch-all rule depends_on the login rules, so a new app fails closed.

  • cognito-app-client: new optional slot input. New outputs client_id_parameter_name, client_secret_parameter_name and request_value. The external pool falls back to its prefix domain, which also fixes the empty COGNITO_DOMAIN for auth: external.

  • app-auth-sync: accepts /javabin/apps/{team}/{app}/login-N/auth. It names the client {team}-{app}.login-N; . cannot occur in app names, so the name cannot collide with another app's client. Credentials go next to the request, and reconcile handles slots. IAM and the EventBridge pattern already match the deeper paths, because their * covers /.

  • identity: the external pool gets:

    • the javabin-external prefix domain (only while no custom domain is configured);
    • a V2 pre-token trigger javabin-auth-claims-external: same zip and role, REGISTRY_GROUPS=false.

    The external pool had no domain and no trigger before. Anyone can sign up there with any email, so registry groups are never granted there; native groups and per-client allowed_groups still apply.

  • Docs: app-yaml-reference.md (new routing.login section: what the app receives in x-amzn-oidc-* headers, caveats, and a static-pages recipe using nginx-unprivileged), reusable-modules.md, lambda-functions.md, CLAUDE.md.

Why authenticate-oidc and not authenticate-cognito: the Cognito action needs cognito-idp:DescribeUserPoolClient on the platform pools. The developer boundary denies that, and allowing it would let any team read every team's client secret. Group checks reuse the existing auth-claims allowed_groups refusal. That check works per client, which is why there is one client per rule. Cognito's session means a user still signs in only once.

Design: explore/auth-proxy-design.md (outside the repo).

Review fixes (second commit)

  • Internal pool locked to Google Workspace. admin_create_user_config { allow_admin_create_user_only = true } stops self sign-up. app-auth-sync no longer adds COGNITO to internal-pool clients, only the pool's IdPs (GoogleSAML). That matches the live cupcake-cupcake client, which already has only GoogleSAML. Before this, a login: internal rule would have let anyone register with a password through the hosted UI.
  • Email spoofing closed. Both pools set attributes_require_verification_before_update = ["email"]. auth-claims grants registry groups only to federated users (cognito:user_status = EXTERNAL_PROVIDER) or users with email_verified = true. Live SAML users have email_verified=false, which is why the federated check is needed.
  • ALB session cookie is now javabin-auth-p<N>-<hash of slot, pool, groups>. Tightening groups or reordering rules now forces a fresh sign-in, so an older session from a looser rule no longer works.
  • External prefix domain is always created; adding a custom domain later no longer deletes it.
  • Bare prefix. On protected rules, /x/* now also covers /x. The warning and the docs now also cover percent-encoding and ...
  • Docs:
    • external groups must be created by hand for now;
    • leave room in priorities (multiples of 20);
    • how a refused user sees the denial;
    • login does not proxy to sites hosted off the platform.

Decision for you to confirm

"Reverse proxy" here means a sign-in gate in front of apps running on the platform (any ECS app, or an nginx container for static pages). The ALB cannot forward to upstream URLs outside the platform. Proxying to external sites would need a small proxy container or CloudFront, and is not in this PR.

How verified

  • python3 -m unittest discover scripts/tests (50) and terraform/lambda-src/tests (81): all pass. New tests cover:
    • generator: the three forms, priorities, every validation error, the warning, unchanged output without login, terraform fmt clean;
    • app-auth-sync: slot names, credential paths, sync/delete/reconcile with slots, invalid slots ignored;
    • auth-claims: REGISTRY_GROUPS=false; unverified native emails get no registry groups;
    • app-auth-sync: internal clients (main and slot) get no COGNITO;
    • generator: the bare prefix is added and counted toward the 4-path limit.
  • terraform fmt -recursive -check passes. terraform validate passes for modules/service-routing, modules/cognito-app-client and a generated sample app (internal + external + public rules, module sources pointed at this branch).
  • Read-only terraform plan of that sample app against the account (local state, nothing applied):
    • one SSM request per protected rule (/javabin/apps/core/wiki/login-N/auth);
    • listener rules at 119/118/117/116 plus the host rule at 120;
    • internal rules point at javabin-internal with identity_provider=GoogleSAML; the external rule points at javabin-external.auth... and the external pool's issuer;
    • credential reads deferred until after the wait.
  • Expected platform plan:
    • identity adds the external pool's lambda_config, auth_claims_external + permission and the prefix domain;
    • in-place updates to both pools (admin-only create on internal, email verification before update on both);
    • the app-auth-sync and auth-claims zips;
    • no ALB or app changes until an app opts in.

Follow-ups

  • Opt one app in (a tiny nginx app or the test app) and check end to end: anonymous user is redirected to Google, a member gets the page, a non-member is refused by Cognito.
  • Native groups in the external pool have no management path yet; until one exists, an admin creates them by hand (documented). The external pool also has no social or other IdPs yet, so attendees use email/password for now.
  • Optional defence in depth: a pre-sign-up trigger that rejects native sign-ups on the internal pool. Not added: admin-only create plus no COGNITO provider already close sign-up.
  • Overlap: the security workstream has an uncommitted fix/cognito-internal-lockdown that does a broader version of the internal-pool lockdown (per-pool IdP list, @java.no domain check). Whichever PR merges second needs a rebase in app_auth_sync/handler.py, auth_claims/handler.py and identity/main.tf.
  • Security workstream: the ALB client secret ends up in app Terraform state and in the binary plan artifact. The text plan masks it. The plan-artifacts bucket is exempt from DenyXTeam.
  • Rule priorities can collide with a neighbouring app; this only shows up at apply as PriorityInUse. The docs tell developers to leave room. A registry check that reserves [priority-10, priority] per app is the proper fix (registry follow-up). Today cupcake-backend (101) cannot add rules because cupcake holds 100.

🤖 Generated with Claude Code

app.yaml `routing.login` lets an app require Cognito sign-in on chosen
paths, per path choosing the pool (internal / external) and the groups
that may pass. The platform ALB does the sign-in (authenticate-oidc), so
simple apps and static pages need no auth code.

- generate-modules.py: validate routing.login (string / mapping / list
  forms), emit login_rules at priorities directly below routing.priority,
  warn when /* is not covered (path patterns are case-sensitive).
- service-routing: per protected rule, a Cognito client (nested
  cognito-app-client, slot login-N), a wait for app-auth-sync, and an
  authenticate-oidc + forward listener rule; public rules forward only.
  The host rule depends on them, so a new app fails closed.
- cognito-app-client: optional slot; external pool falls back to its
  Cognito prefix domain.
- app-auth-sync: requests under /apps/<team>/<app>/login-N/auth create
  client <team>-<app>.login-N with credentials next to the request.
- identity: external pool gets the javabin-external prefix domain and a
  pre-token trigger (auth-claims with REGISTRY_GROUPS=false), so
  allowed_groups is enforced there without granting registry groups.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@Alexanderamiri
Alexanderamiri requested a review from a team as a code owner October 24, 2026 13:07
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 3 to add, 3 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:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # module.identity.aws_cognito_user_pool.external will be updated in-place
  ~ resource "aws_cognito_user_pool" "external" {
        id                        = "eu-central-1_gdFOsE4EM"
        name                      = "javabin-external"
        tags                      = {
            "Name" = "javabin-external"
        }
        # (11 unchanged attributes hidden)

      + lambda_config {
          + pre_token_generation_config {
              + lambda_arn     = (known after apply)
              + lambda_version = "V2_0"
            }
        }

        # (7 unchanged blocks hidden)
    }

  # module.identity.aws_cognito_user_pool_domain.external_prefix[0] will be created
  + resource "aws_cognito_user_pool_domain" "external_prefix" {
      + aws_account_id                  = (known after apply)
      + cloudfront_distribution         = (known after apply)
      + cloudfront_distribution_arn     = (known after apply)
      + cloudfront_distribution_zone_id = (known after apply)
      + domain                          = "javabin-external"
      + id                              = (known after apply)
      + managed_login_version           = (known after apply)
      + s3_bucket                       = (known after apply)
      + user_pool_id                    = "eu-central-1_gdFOsE4EM"
      + version                         = (known after apply)
    }

  # module.identity.aws_lambda_function.auth_claims will be updated in-place
  ~ resource "aws_lambda_function" "auth_claims" {
        id                             = "javabin-auth-claims"
      ~ last_modified                  = "2026-10-23T15:36:35.282+0000" -> (known after apply)
      ~ source_code_hash               = "T0XK8BQ05sxP5MVCsIGH+JuY++sBeQohaGTkvMdm81M=" -> "G7CVVKTKcWux3uaInvJ/HPm9sjCf6hpfYCpAgutj0Z0="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.identity.aws_lambda_function.auth_claims_external will be created
  + resource "aws_lambda_function" "auth_claims_external" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "identity/builds/auth_claims.zip"
      + function_name                  = "javabin-auth-claims-external"
      + handler                        = "handler.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 256
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + reserved_concurrent_executions = -1
      + role                           = "arn:aws:iam::553637109631:role/javabin-auth-claims"
      + runtime                        = "python3.12"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "G7CVVKTKcWux3uaInvJ/HPm9sjCf6hpfYCpAgutj0Z0="
      + source_code_size               = (known after apply)
      + tags_all                       = {
          + "environment" = "production"
          + "managed-by"  = "terraform"
          + "repo"        = "javaBin/platform"
          + "service"     = "platform"
          + "team"        = "platform"
        }
      + timeout                        = 5
      + version                        = (known after apply)

      + environment {
          + variables = {
              + "MEMBERSHIPS_TABLE" = "javabin-auth-memberships"
              + "REGISTRY_GROUPS"   = "false"
            }
        }
    }

  # module.identity.aws_lambda_permission.auth_claims_external_cognito will be created
  + resource "aws_lambda_permission" "auth_claims_external_cognito" {
      + action              = "lambda:InvokeFunction"
      + function_name       = "javabin-auth-claims-external"
      + id                  = (known after apply)
      + principal           = "cognito-idp.amazonaws.com"
      + source_arn          = "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_gdFOsE4EM"
      + statement_id        = "AllowCognitoExternalPool"
      + statement_id_prefix = (known after apply)
    }

  # 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-23T15:31:34.000+0000" -> (known after apply)
      ~ source_code_hash               = "sIha3Wb5dyMLBep00WQdQ/yhswtf4SXSuDzJX076WoE=" -> "K4tkidVltbXKluS15V/Z20AuRxysldUSX0uibNFSj+0="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 3 to add, 3 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: 🟢 LOW

Plan adds an external Cognito user pool domain, a new external auth-claims Lambda with its Cognito invoke permission, and updates the Cognito user pool (pre-token-generation hook) plus two existing Lambdas' code; 0 destroys, no database or network changes.

  • 💥 [destruction] No resources are destroyed (Plan: 3 to add, 3 to change, 0 to destroy). The Cognito user pool is updated in-place, not replaced, so existing user pool ID and users are preserved.
  • 🔒 [security] Cognito user pool gains a pre_token_generation lambda trigger (V2_0) that invokes the new javabin-auth-claims-external Lambda. This Lambda controls token claims, so its code and IAM role (javabin-auth-claims, shared with the existing auth_claims function) should be reviewed. The Lambda permission is scoped to the specific user pool ARN, which is good.
  • 🔒 [security] The new Lambda reuses the existing role arn:aws:iam::553637109631:role/javabin-auth-claims rather than a dedicated role. Confirm that role's permissions are least-privilege for the external pool, since it reads the javabin-auth-memberships table. The REGISTRY_GROUPS variable is set to false in the new function; verify this is intended.
  • 💰 [cost] New billable resources are limited to a Lambda function (256 MB, 5s timeout, invoked per token issuance) and a Cognito user pool domain. Cost impact is expected to be low.
  • ✅ [routine] Code updates to aws_lambda_function.auth_claims and module.lambdas app_auth_sync (source_code_hash changes) are routine deployments. The new external Lambda is built from identity/builds/auth_claims.zip, the same artifact as the existing function, so its code hash matches.

- internal pool: no self sign-up (admin-only create) and no COGNITO
  provider on its app clients, so login: internal admits only
  Google Workspace users
- both pools: email changes stay pending until verified; auth-claims
  grants registry groups only for federated or verified emails
- ALB session cookie named after slot, pool and groups, so tightening
  groups or reordering rules forces a fresh sign-in
- external pool prefix domain kept unconditionally
- protected /x/* rules also cover bare /x; broader path-bypass warning
- docs: external groups are manual for now, priority spacing, denial UX,
  no proxying to off-platform sites

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 3 to add, 4 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:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # module.identity.aws_cognito_user_pool.external will be updated in-place
  ~ resource "aws_cognito_user_pool" "external" {
        id                        = "eu-central-1_gdFOsE4EM"
        name                      = "javabin-external"
        tags                      = {
            "Name" = "javabin-external"
        }
        # (11 unchanged attributes hidden)

      + lambda_config {
          + pre_token_generation_config {
              + lambda_arn     = (known after apply)
              + lambda_version = "V2_0"
            }
        }

      + user_attribute_update_settings {
          + attributes_require_verification_before_update = [
              + "email",
            ]
        }

        # (7 unchanged blocks hidden)
    }

  # module.identity.aws_cognito_user_pool.internal will be updated in-place
  ~ resource "aws_cognito_user_pool" "internal" {
        id                        = "eu-central-1_Icikv3dtD"
        name                      = "javabin-internal"
        tags                      = {
            "Name" = "javabin-internal"
        }
        # (12 unchanged attributes hidden)

      ~ admin_create_user_config {
          ~ allow_admin_create_user_only = false -> true
        }

      + user_attribute_update_settings {
          + attributes_require_verification_before_update = [
              + "email",
            ]
        }

        # (7 unchanged blocks hidden)
    }

  # module.identity.aws_cognito_user_pool_domain.external_prefix will be created
  + resource "aws_cognito_user_pool_domain" "external_prefix" {
      + aws_account_id                  = (known after apply)
      + cloudfront_distribution         = (known after apply)
      + cloudfront_distribution_arn     = (known after apply)
      + cloudfront_distribution_zone_id = (known after apply)
      + domain                          = "javabin-external"
      + id                              = (known after apply)
      + managed_login_version           = (known after apply)
      + s3_bucket                       = (known after apply)
      + user_pool_id                    = "eu-central-1_gdFOsE4EM"
      + version                         = (known after apply)
    }

  # module.identity.aws_lambda_function.auth_claims will be updated in-place
  ~ resource "aws_lambda_function" "auth_claims" {
        id                             = "javabin-auth-claims"
      ~ last_modified                  = "2026-10-23T15:36:35.282+0000" -> (known after apply)
      ~ source_code_hash               = "T0XK8BQ05sxP5MVCsIGH+JuY++sBeQohaGTkvMdm81M=" -> "smiN/WS0Z1s5c2TZHz7NkB7TpwSvdsVfB3YBq7ScYXw="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.identity.aws_lambda_function.auth_claims_external will be created
  + resource "aws_lambda_function" "auth_claims_external" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + code_sha256                    = (known after apply)
      + filename                       = "identity/builds/auth_claims.zip"
      + function_name                  = "javabin-auth-claims-external"
      + handler                        = "handler.handler"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 256
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + qualified_invoke_arn           = (known after apply)
      + reserved_concurrent_executions = -1
      + role                           = "arn:aws:iam::553637109631:role/javabin-auth-claims"
      + runtime                        = "python3.12"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + skip_destroy                   = false
      + source_code_hash               = "smiN/WS0Z1s5c2TZHz7NkB7TpwSvdsVfB3YBq7ScYXw="
      + source_code_size               = (known after apply)
      + tags_all                       = {
          + "environment" = "production"
          + "managed-by"  = "terraform"
          + "repo"        = "javaBin/platform"
          + "service"     = "platform"
          + "team"        = "platform"
        }
      + timeout                        = 5
      + version                        = (known after apply)

      + environment {
          + variables = {
              + "MEMBERSHIPS_TABLE" = "javabin-auth-memberships"
              + "REGISTRY_GROUPS"   = "false"
            }
        }
    }

  # module.identity.aws_lambda_permission.auth_claims_external_cognito will be created
  + resource "aws_lambda_permission" "auth_claims_external_cognito" {
      + action              = "lambda:InvokeFunction"
      + function_name       = "javabin-auth-claims-external"
      + id                  = (known after apply)
      + principal           = "cognito-idp.amazonaws.com"
      + source_arn          = "arn:aws:cognito-idp:eu-central-1:553637109631:userpool/eu-central-1_gdFOsE4EM"
      + statement_id        = "AllowCognitoExternalPool"
      + statement_id_prefix = (known after apply)
    }

  # 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-23T15:31:34.000+0000" -> (known after apply)
      ~ source_code_hash               = "sIha3Wb5dyMLBep00WQdQ/yhswtf4SXSuDzJX076WoE=" -> "KWro4WgUzrH9qadMzlFpjzqXpOZyWEUl6a9x6xziRTg="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 3 to add, 4 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

Plan adds 3 resources (external Cognito domain, external auth-claims Lambda and its Cognito invoke permission) and updates 4 existing resources (two Cognito user pools, auth-claims Lambda code, app-auth-sync Lambda code); nothing is destroyed.

  • 🔒 [security] The internal Cognito user pool (javabin-internal) changes allow_admin_create_user_only from false to true. This means only admins can create users, so self-signup is disabled. This is intended hardening but should be confirmed as deliberate, since it may break any self-registration flow that relies on the internal pool.
  • 🔒 [security] The new aws_lambda_permission lets cognito-idp.amazonaws.com invoke javabin-auth-claims-external, scoped by source_arn to the external user pool (eu-central-1_gdFOsE4EM). This is correctly scoped. The external pool's pre-token-generation trigger is also wired to this Lambda, which can modify token claims, so its code and IAM role should be reviewed.
  • 🔒 [security] The new external Lambda reuses the existing execution role arn:aws:iam::553637109631:role/javabin-auth-claims, so it inherits that role's permissions, including read access to the memberships table. Confirm the role is least-privilege for the external use case.
  • ✅ [routine] The Lambda code for javabin-auth-claims and javabin-app-auth-sync is updated in place with new source_code_hash values, and the external pool gets a new lambda_config and user_attribute_update_settings (email verification before update). These are routine code deployments with no infrastructure replacement.
  • 💰 [cost] New billable resources are limited to one Lambda function (256 MB, 5s timeout, invoked per Cognito auth event) and one Cognito user pool domain. Cost impact should be minimal.
  • 💥 [destruction] The plan has 0 destroys and no force-replacements. All changes are create or in-place update.

Alexanderamiri added a commit that referenced this pull request Oct 24, 2026
## Why

Part of #157. The internal pool (`javabin-internal`,
`eu-central-1_Icikv3dtD`) is open to the internet today:

- `AllowAdminCreateUserOnly` is `false`, so self sign-up is on.
- app-auth-sync puts `COGNITO` on 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: internal` and no `groups` accepts that
user. Nothing checks the email domain on the server side.

The `COGNITO` entry 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.tf`**
- The internal pool gets `admin_create_user_config {
allow_admin_create_user_only = true }`.
  - The internal `auth-claims` function 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"]`) and `external_identity_providers` (default
`["COGNITO"]`).
- They are declared in the identity module, output from it, and passed
to app-auth-sync as `INTERNAL_IDENTITY_PROVIDERS` /
`EXTERNAL_IDENTITY_PROVIDERS`.
- Validation: the internal list must not include `COGNITO` and must not
be empty.
- **app-auth-sync**
  - Each client gets exactly its pool's IdP list.
- If a configured IdP doesn't exist in the pool, the request is skipped:
an `ERROR` log and `status: error` (reconcile lists it under `error`).
Nothing is created, moved or wiped, and the existing client keeps
working.
- Settings are resolved before the pool-switch cleanup, so a
misconfigured target pool can't delete the client in the old pool.
- **auth-claims**: when `REQUIRED_EMAIL_DOMAIN` is set, a token is
refused unless both of these hold:
  - the email ends with `@java.no` (case-insensitive);
- the user is federated through a listed IdP (from the `identities`
attribute, or the `<Provider>_` username), or has `email_verified=true`.

The user sees "Sign in with your @java.no account." Logs mask the
address as `e***@java.no`.
- **Review follow-ups**
- app-auth-sync rejects `aws.cognito.signin.user.admin` for `pool:
internal` (status `invalid`, client not created or changed). That scope
would let a signed-in user call `UpdateUserAttributes` and point their
email at another `@java.no` user, whose registry groups auth-claims
would then grant. No live request uses it (only cupcake, with `openid
email profile`). The external pool still allows it.
- New `aws_cloudwatch_log_metric_filter` + `aws_cloudwatch_metric_alarm`
`javabin-app-auth-sync-skipped` on `client left unchanged` in
`/aws/lambda/javabin-app-auth-sync`, posting to `javabin-alerts` (Slack
via slack-alert). The `javabin-alerts` topic policy gets an
`AllowCloudWatchAlarmsPublish` statement scoped to this account's
alarms.
- `internal_identity_providers` documents that each entry is trusted to
vouch for email, so it must release only Workspace accounts (Google OIDC
needs an Internal-consent client, or an `email_verified`/`hd` check
first).
  - auth-claims masks the email in the group-denial log too.
- **Docs**: `docs/lambda-functions.md` (both functions) and the
`docs/app-yaml-reference.md` auth section, which covers no password form
or sign-up, the `@java.no` rule, and how a denial arrives as
`error_description`.

## How verified

- Unit tests: `python3 -m unittest discover -s
terraform/lambda-src/tests` (79 OK) and `scripts/tests` (43 OK). New
tests cover:
- the fixed IdP list, and the reconcile reverting a console edit to the
list;
- env config, the missing-IdP skip on create, update, pool switch and
reconcile, and an empty list;
- for the domain rule: SAML users with `email_verified=false`, lookalike
domains, native users with and without a verified email, other IdPs, and
a pool without the rule.
- `terraform fmt -check -recursive` and `terraform validate` pass.
- Read-only checks against live state:
- **Users:** the pool has 4 users. All are `GoogleSAML_…`, `@java.no`,
`EXTERNAL_PROVIDER`, `email_verified=false`, identities provider
`GoogleSAML`. All 4 pass through the federated-IdP branch. This exact
shape is a unit test.
- **Auth requests:** there is one, `/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 is `client: none` and is
unaffected.
- **IdPs:** the internal pool has exactly one IdP, `GoogleSAML`, so the
configured list resolves. The external pool keeps `["COGNITO"]`, which
is what it gets today (it has no IdPs).
- **Plan expectation:**
- in-place updates: `aws_cognito_user_pool.internal`
(`admin_create_user_config` is not ForceNew),
`aws_lambda_function.auth_claims` (env) and
`aws_lambda_function.app_auth_sync` (env + code),
`aws_sns_topic_policy.alerts` (new statement);
- creates: the log metric filter and the alarm (the log group already
exists);
  - no destroys, no pool replacement.

## Rollout & user actions

- None needed after merge. The CI apply updates the pool and both
Lambdas.
- The next auth-request change, or the 03:30 UTC reconcile, sets every
internal client to `[GoogleSAML]`. To apply it right away, run `aws
lambda invoke --function-name javabin-app-auth-sync --payload
'{"action":"reconcile"}' --cli-binary-format raw-in-base64-out
/dev/stdout`.
- Existing sessions keep working. Refresh tokens pass the domain check
because all current users qualify.

## Follow-ups

- **Interaction with #158 (`feat/alb-login`).** That PR edits the same
functions in app-auth-sync (`client_settings`, `sync`, `reconcile`) and
adds `auth_claims_external` from the same handler. The external function
doesn't set `REQUIRED_EMAIL_DOMAIN`, so it skips the domain check, which
is intended.
- Rebase on top of this PR: `client_settings()` now takes `pool` first.
- `service-routing`'s hardcoded `internal_identity_provider =
"GoogleSAML"` can be dropped once clients have a single IdP. The hosted
UI already redirects.
- **Overlap with #165.** It adds `deletion_protection = "ACTIVE"` to
both pools (raised in review here too; left to #165 to avoid a
duplicate). The change is a different attribute, so expect a trivial
text merge at most.
- **Not done here:** `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.
- **#157 next step: switching to Google OIDC.**
  - Set `internal_identity_providers = ["Google"]` once the IdP exists.
- Google users then count as federated. That is safe only with an
Internal-consent GCP client (java.no accounts only), as the design note
proposes. Otherwise, map `email_verified` and drop Google from the
trusted list.
- Bring `GoogleSAML` under Terraform with an `import` block, or remove
it after cutover.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
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) <[email protected]>

This branch has not been deployed

No deployments
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