Skip to content

Commit 663d247

Browse files
fix(identity): lock down the internal Cognito pool (#166)
## 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) <noreply@anthropic.com>
1 parent dd62760 commit 663d247

13 files changed

Lines changed: 422 additions & 24 deletions

File tree

‎docs/app-yaml-reference.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ auth:
216216
COGNITO_ISSUER_URL: NUXT_PUBLIC_OIDC_AUTHORITY
217217
```
218218

219+
#### `scopes`
220+
221+
Any of `openid`, `email`, `profile`, `phone`, `aws.cognito.signin.user.admin`. The `internal` pool refuses `aws.cognito.signin.user.admin`: it lets a signed-in user change their own attributes, including the email the platform uses to look up group membership. A request with it is rejected and the client is not created or changed.
222+
219223
#### `client`
220224

221225
| Value | For | You get |
@@ -232,7 +236,7 @@ Changing `client` between `confidential` and `public` replaces the Cognito clien
232236

233237
Both forms are supported and produce the same wiring.
234238

235-
- `internal` — Javabin heroes. Sign-in offers every identity provider on the pool (Google Workspace SAML) plus Cognito users.
239+
- `internal` — Javabin heroes. The hosted UI sends users straight to Google Workspace (`GoogleSAML`), so apps don't need an `identity_provider` parameter. There is no password form or sign-up. Only `@java.no` accounts get a token; anyone else is refused at sign-in.
236240
- `external` — public users.
237241
- `none` or omitted — no Cognito integration.
238242

@@ -271,7 +275,7 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
271275

272276
Tokens from the internal pool carry the user's registry groups in `cognito:groups` (ID and access token): every group in `javaBin/registry` `groups/groups.yaml` with `cognito: true` the user is a member of, plus `team-{team}` for teams they belong to. The `javabin-auth-claims` trigger adds them at every sign-in and refresh, from membership team-provisioner records on each registry merge. A registry change reaches tokens at the user's next sign-in or token refresh.
273277

274-
`auth.groups` restricts the client: a user in none of the listed groups is refused at sign-in, so the app never receives a token for them. Apps should still check `cognito:groups` for finer-grained authorization (e.g. admin features).
278+
`auth.groups` restricts the client: a user in none of the listed groups is refused at sign-in, so the app never receives a token for them. The refusal (like the internal pool's `@java.no` check) comes back to your callback URL as `error=…&error_description=…PreTokenGeneration failed…`; show `error_description` to the user rather than a blank page. Apps should still check `cognito:groups` for finer-grained authorization (e.g. admin features).
275279

276280
No Cognito groups are created from `app.yaml`; add groups in the registry.
277281

‎docs/lambda-functions.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
125125
**Trigger:** EventBridge "Parameter Store Change" for `/javabin/apps/*/*/auth` (Create/Update/Delete), plus a daily `{"action": "reconcile"}` at 03:30 UTC
126126
**Purpose:** Creates, updates and deletes the Cognito app clients requested by `app.yaml` `auth:`. The request parameter's path identifies the team (only that team can write there), so no caller-supplied identity is trusted.
127127

128-
**Flow:** Re-read the request → validate (pool, client_type, https/localhost URLs, scopes, allowed_groups) → upsert client `{team}-{app}` in the requested pool with every pool IdP + `COGNITO` (a client_type change replaces the client, since the secret setting is immutable) → remove it from the other pool → write `/javabin/apps/{team}/{app}/cognito-client-id` (String) and, for confidential clients, `cognito-client-secret` (SecureString), tagged `team={team}` → record `allowed_groups` in `javabin-auth-memberships`. A missing request deletes the client and both parameters. Reconcile also removes clients whose credential parameter exists but whose request is gone; clients created by other means are never touched.
128+
**Flow:** Re-read the request → validate (pool, client_type, https/localhost URLs, scopes, allowed_groups) → upsert client `{team}-{app}` in the requested pool with that pool's fixed sign-in IdP list (a client_type change replaces the client, since the secret setting is immutable) → remove it from the other pool → write `/javabin/apps/{team}/{app}/cognito-client-id` (String) and, for confidential clients, `cognito-client-secret` (SecureString), tagged `team={team}` → record `allowed_groups` in `javabin-auth-memberships`. A missing request deletes the client and both parameters. Reconcile also removes clients whose credential parameter exists but whose request is gone; clients created by other means are never touched.
129+
130+
**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. 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. The CloudWatch alarm `javabin-app-auth-sync-skipped` (metric filter on `client left unchanged`) posts to `javabin-alerts` when this happens. 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).
131+
132+
**Environment vars:** `PROJECT`, `MEMBERSHIPS_TABLE`, `INTERNAL_IDENTITY_PROVIDERS`, `EXTERNAL_IDENTITY_PROVIDERS`
129133

130134
**Concurrency:** reserved concurrency 1, so two changes to one app cannot race into duplicate clients.
131135

@@ -134,7 +138,11 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
134138
## auth-claims
135139

136140
**Trigger:** Cognito pre-token-generation (V2) on the internal pool (`javabin-internal`), every sign-in and token refresh
137-
**Purpose:** Puts the user's registry groups into `cognito:groups` (ID and access token), and refuses sign-in when the app client has `allowed_groups` and the user is in none of them.
141+
**Purpose:** Refuses tokens for accounts outside the Workspace domain, puts the user's registry groups into `cognito:groups` (ID and access token), and refuses sign-in when the app client has `allowed_groups` and the user is in none of them.
142+
143+
**Domain check:** with `REQUIRED_EMAIL_DOMAIN` set (internal pool: `var.google_workspace_domain`, `java.no`), a token is issued only when the email ends with `@java.no` (case-insensitive) **and** the user signed in through one of `FEDERATED_IDENTITY_PROVIDERS` (the internal pool's IdP list, `GoogleSAML`) or has `email_verified=true`. Federated users are recognised by the `identities` attribute, or by their `<Provider>_` username. Anyone else gets "Sign in with your @java.no account." on the app's callback. Every IdP in that list is trusted to vouch for the email (no `email_verified` check), so it must release only Workspace accounts; see the comment on `internal_identity_providers` before adding one (for example a Google OIDC IdP). A function without `REQUIRED_EMAIL_DOMAIN` skips the check. The internal pool also has self sign-up disabled (`allow_admin_create_user_only`).
144+
145+
**Environment vars:** `MEMBERSHIPS_TABLE`, `REQUIRED_EMAIL_DOMAIN`, `FEDERATED_IDENTITY_PROVIDERS`
138146

139147
**Data:** `javabin-auth-memberships` (DynamoDB). `group#<name>` items (members' emails) are written by team-provisioner on registry merges; `client#<client_id>` items (`allowed_groups`) by app-auth-sync. Memberships are cached for 60 s per container.
140148

‎terraform/lambda-src/app_auth_sync/handler.py‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
where the app's ECS task picks them up via its team's execution role.
1515
Public clients (browser apps using PKCE) get no secret and no secret parameter.
1616
17+
Each pool has a fixed sign-in IdP list (INTERNAL_IDENTITY_PROVIDERS /
18+
EXTERNAL_IDENTITY_PROVIDERS, comma-separated, set by Terraform). Every client
19+
in the pool gets exactly that list, so with a single IdP the hosted UI sends
20+
the user straight to it (no password form, no sign-up link). If a configured
21+
IdP does not exist in the pool, the request is skipped and logged as an error;
22+
the existing client is left as it is.
23+
1724
The request's allowed groups are written to the auth memberships table as
1825
{pk: "client#<client_id>", allowed_groups: [...]}; the auth-claims trigger
1926
refuses tokens for users in none of them.
@@ -40,12 +47,27 @@
4047
"internal": os.environ.get("INTERNAL_POOL_NAME", f"{PROJECT}-internal"),
4148
"external": os.environ.get("EXTERNAL_POOL_NAME", f"{PROJECT}-external"),
4249
}
50+
51+
def _idp_list(value):
52+
return [p.strip() for p in value.split(",") if p.strip()]
53+
54+
55+
# Sign-in IdPs per pool. COGNITO means local username/password users.
56+
POOL_IDPS = {
57+
"internal": _idp_list(os.environ.get("INTERNAL_IDENTITY_PROVIDERS", "GoogleSAML")),
58+
"external": _idp_list(os.environ.get("EXTERNAL_IDENTITY_PROVIDERS", "COGNITO")),
59+
}
4360
MEMBERSHIPS_TABLE = os.environ.get("MEMBERSHIPS_TABLE", "")
4461
DEFAULT_SCOPES = ["openid", "email", "profile"]
4562
CLIENT_TYPES = ("confidential", "public")
4663
# Cognito allows plain http only for localhost, for local development
4764
LOCAL_URL = re.compile(r"^http://(localhost|127\.0\.0\.1)(:\d+)?(/.*)?$")
4865
ALLOWED_SCOPES = {"openid", "email", "profile", "phone", "aws.cognito.signin.user.admin"}
66+
# aws.cognito.signin.user.admin lets the signed-in user call UpdateUserAttributes
67+
# with their access token. In the internal pool, auth-claims trusts a federated
68+
# user's email and looks up registry groups by it, so a user who could rewrite
69+
# their own email could take another user's groups.
70+
POOL_FORBIDDEN_SCOPES = {"internal": {"aws.cognito.signin.user.admin"}}
4971

5072
APPS_PREFIX = f"/{PROJECT}/apps/"
5173
REQUEST_RE = re.compile(rf"^/{re.escape(PROJECT)}/apps/(?P<team>[a-z]{{1,12}})/(?P<app>[a-z][a-z0-9-]{{0,19}})/auth$")
@@ -59,6 +81,10 @@ class InvalidRequest(ValueError):
5981
pass
6082

6183

84+
class PoolMisconfigured(RuntimeError):
85+
"""The platform side is wrong (not the app's request): skip, keep the client."""
86+
87+
6288
# ---------------------------------------------------------------------------
6389
# Request parsing
6490
# ---------------------------------------------------------------------------
@@ -95,6 +121,9 @@ def urls(key, required):
95121
scopes = req.get("scopes") or DEFAULT_SCOPES
96122
if not isinstance(scopes, list) or not set(scopes) <= ALLOWED_SCOPES:
97123
raise InvalidRequest(f"scopes must be a subset of {sorted(ALLOWED_SCOPES)}")
124+
forbidden = set(scopes) & POOL_FORBIDDEN_SCOPES.get(pool, set())
125+
if forbidden:
126+
raise InvalidRequest(f"scopes {sorted(forbidden)} are not allowed in the {pool} pool")
98127

99128
client_type = req.get("client_type") or "confidential"
100129
if client_type not in CLIENT_TYPES:
@@ -141,15 +170,27 @@ def find_client(user_pool_id, client_name):
141170
return None
142171

143172

144-
def identity_providers(user_pool_id):
145-
"""Every IdP configured on the pool (e.g. GoogleSAML) plus local users."""
146-
names = []
147-
for page in cognito.get_paginator("list_identity_providers").paginate(UserPoolId=user_pool_id, MaxResults=60):
148-
names += [p["ProviderName"] for p in page["Providers"]]
149-
return names + ["COGNITO"]
173+
_idp_cache = {}
174+
175+
176+
def identity_providers(pool, user_pool_id):
177+
"""The pool's configured sign-in IdPs, after checking each one exists."""
178+
wanted = POOL_IDPS[pool]
179+
if not wanted:
180+
raise PoolMisconfigured(f"no identity providers configured for the {pool} pool")
181+
if user_pool_id not in _idp_cache:
182+
names = set()
183+
for page in cognito.get_paginator("list_identity_providers").paginate(UserPoolId=user_pool_id, MaxResults=60):
184+
names |= {p["ProviderName"] for p in page["Providers"]}
185+
_idp_cache[user_pool_id] = names
186+
existing = _idp_cache[user_pool_id]
187+
missing = [p for p in wanted if p != "COGNITO" and p not in existing]
188+
if missing:
189+
raise PoolMisconfigured(f"{pool} pool has no identity provider {missing}; it has {sorted(existing)}")
190+
return list(wanted)
150191

151192

152-
def client_settings(user_pool_id, client_name, req):
193+
def client_settings(pool, user_pool_id, client_name, req):
153194
return {
154195
"UserPoolId": user_pool_id,
155196
"ClientName": client_name,
@@ -158,7 +199,7 @@ def client_settings(user_pool_id, client_name, req):
158199
"AllowedOAuthScopes": req["scopes"],
159200
"CallbackURLs": req["callback_urls"],
160201
"LogoutURLs": req["logout_urls"],
161-
"SupportedIdentityProviders": identity_providers(user_pool_id),
202+
"SupportedIdentityProviders": identity_providers(pool, user_pool_id),
162203
"ExplicitAuthFlows": ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"],
163204
"PreventUserExistenceErrors": "ENABLED",
164205
}
@@ -195,6 +236,10 @@ def _put_param(name, value, secure, team, app):
195236
def sync(team, app, req):
196237
"""Make Cognito match one request. Returns the client ID."""
197238
client_name = f"{team}-{app}"
239+
# Resolve settings first: a misconfigured pool must abort before anything
240+
# (including the pool-switch cleanup below) is changed.
241+
upid = pool_id(req["pool"])
242+
settings = client_settings(req["pool"], upid, client_name, req)
198243

199244
# A pool switch (internal <-> external) leaves no client behind.
200245
for other in POOL_NAMES:
@@ -206,8 +251,6 @@ def sync(team, app, req):
206251
_delete_client_groups(stale)
207252
logger.info("Removed %s from %s pool", client_name, other)
208253

209-
upid = pool_id(req["pool"])
210-
settings = client_settings(upid, client_name, req)
211254
want_secret = req["client_type"] == "confidential"
212255
client_id = find_client(upid, client_name)
213256
if client_id:
@@ -298,7 +341,12 @@ def handle_change(name, operation):
298341
except InvalidRequest as e:
299342
logger.error("Invalid auth request %s: %s", name, e)
300343
return {"status": "invalid", "error": str(e)}
301-
return {"status": "synced", "client": f"{team}-{app}", "client_id": sync(team, app, req)}
344+
try:
345+
client_id = sync(team, app, req)
346+
except PoolMisconfigured as e:
347+
logger.error("Skipped %s-%s, client left unchanged: %s", team, app, e)
348+
return {"status": "error", "error": str(e)}
349+
return {"status": "synced", "client": f"{team}-{app}", "client_id": client_id}
302350

303351

304352
def reconcile():
@@ -311,14 +359,17 @@ def reconcile():
311359
if owner:
312360
wanted[owner] = p["Value"]
313361

314-
results = {"synced": [], "invalid": [], "deleted": []}
362+
results = {"synced": [], "invalid": [], "error": [], "deleted": []}
315363
for (team, app), raw in sorted(wanted.items()):
316364
try:
317365
sync(team, app, parse_request(raw))
318366
results["synced"].append(f"{team}-{app}")
319367
except InvalidRequest as e:
320368
logger.error("Invalid auth request for %s-%s: %s", team, app, e)
321369
results["invalid"].append(f"{team}-{app}")
370+
except PoolMisconfigured as e:
371+
logger.error("Skipped %s-%s, client left unchanged: %s", team, app, e)
372+
results["error"].append(f"{team}-{app}")
322373

323374
# Orphans: clients whose credential parameter exists (so this Lambda made
324375
# them) but whose request is gone. Clients created by other means are
@@ -335,6 +386,8 @@ def reconcile():
335386

336387

337388
def handler(event, context):
389+
# IdPs can be added or removed between invocations of a warm container
390+
_idp_cache.clear()
338391
if event.get("action") == "reconcile":
339392
return reconcile()
340393

‎terraform/lambda-src/auth_claims/handler.py‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,17 @@
1515
refuse the sign-in, so the app never receives a token.
1616
1717
Clients without an entry, or with an empty list, accept any pool user.
18+
19+
When REQUIRED_EMAIL_DOMAIN is set (internal pool only, e.g. java.no), the
20+
trigger first refuses every user whose email is not @<domain> (case-insensitive)
21+
or whose address is not vouched for: the user must come from one of
22+
FEDERATED_IDENTITY_PROVIDERS (the pool's sign-in IdPs, which only release
23+
Workspace accounts, e.g. GoogleSAML) or have email_verified=true. This is the
24+
server-side guard against self-registered or foreign-IdP users in the internal
25+
pool; the IdP's hosted-domain hint is only a UI hint.
1826
"""
1927

28+
import json
2029
import logging
2130
import os
2231
import time
@@ -28,6 +37,9 @@
2837

2938
TABLE = os.environ.get("MEMBERSHIPS_TABLE", "javabin-auth-memberships")
3039
CACHE_SECONDS = int(os.environ.get("CACHE_SECONDS", "60"))
40+
REQUIRED_DOMAIN = os.environ.get("REQUIRED_EMAIL_DOMAIN", "").strip().lower().lstrip("@")
41+
FEDERATED_IDPS = {p.strip() for p in os.environ.get("FEDERATED_IDENTITY_PROVIDERS", "").split(",")
42+
if p.strip() and p.strip() != "COGNITO"}
3143

3244
dynamodb = boto3.client("dynamodb")
3345

@@ -64,14 +76,56 @@ def group_members():
6476
return _cache["groups"]
6577

6678

79+
def _masked(email):
80+
"""e***@java.no — enough to correlate in logs without storing the address."""
81+
if "@" not in email:
82+
return "<no email>"
83+
local, domain = email.split("@", 1)
84+
return f"{local[:1]}***@{domain}"
85+
86+
6787
def allowed_groups(client_id):
6888
item = dynamodb.get_item(TableName=TABLE, Key={"pk": {"S": f"client#{client_id}"}}).get("Item")
6989
return set(_strings(item.get("allowed_groups"))) if item else set()
7090

7191

92+
def _providers(event, attrs):
93+
"""Names of the federated IdPs this user signed in through (empty for native users)."""
94+
names = set()
95+
try:
96+
identities = json.loads(attrs.get("identities") or "[]")
97+
except (TypeError, ValueError):
98+
identities = []
99+
if isinstance(identities, list):
100+
names |= {i.get("providerName") for i in identities if isinstance(i, dict) and i.get("providerName")}
101+
# Federated usernames are "<ProviderName>_<id>"; covers events without
102+
# identities. Not spoofable: the pool uses email as the sign-in alias, so
103+
# Cognito assigns native users a UUID username.
104+
user_name = event.get("userName") or ""
105+
names |= {p for p in FEDERATED_IDPS if user_name.startswith(p + "_")}
106+
return names
107+
108+
109+
def domain_allowed(event, attrs, email):
110+
"""True when the pool has no domain rule, or the user satisfies it."""
111+
if not REQUIRED_DOMAIN:
112+
return True
113+
if not email.endswith("@" + REQUIRED_DOMAIN):
114+
return False
115+
if _providers(event, attrs) & FEDERATED_IDPS:
116+
return True
117+
return str(attrs.get("email_verified", "")).lower() == "true"
118+
119+
72120
def handler(event, context):
73121
request = event.get("request", {})
74-
email = (request.get("userAttributes", {}).get("email") or "").lower()
122+
attrs = request.get("userAttributes") or {}
123+
email = (attrs.get("email") or "").lower()
124+
125+
if not domain_allowed(event, attrs, email):
126+
logger.info("Denied %s: not a verified @%s account", _masked(email), REQUIRED_DOMAIN)
127+
raise AccessDenied(f"Sign in with your @{REQUIRED_DOMAIN} account.")
128+
75129
client_id = event.get("callerContext", {}).get("clientId", "")
76130
native = (request.get("groupConfiguration") or {}).get("groupsToOverride") or []
77131

@@ -80,7 +134,7 @@ def handler(event, context):
80134

81135
allowed = allowed_groups(client_id) if client_id else set()
82136
if allowed and not allowed & set(groups):
83-
logger.info("Denied %s for client %s: needs one of %s", email or "<no email>", client_id, sorted(allowed))
137+
logger.info("Denied %s for client %s: needs one of %s", _masked(email), client_id, sorted(allowed))
84138
raise AccessDenied("You are not in a group that has access to this app.")
85139

86140
event["response"] = {

0 commit comments

Comments
 (0)