Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ auth:
COGNITO_ISSUER_URL: NUXT_PUBLIC_OIDC_AUTHORITY
```

#### `scopes`

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.

#### `client`

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

Both forms are supported and produce the same wiring.

- `internal` — Javabin heroes. Sign-in offers every identity provider on the pool (Google Workspace SAML) plus Cognito users.
- `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.
- `external` — public users.
- `none` or omitted — no Cognito integration.

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

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.

`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).
`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).

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

Expand Down
12 changes: 10 additions & 2 deletions docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
**Trigger:** EventBridge "Parameter Store Change" for `/javabin/apps/*/*/auth` (Create/Update/Delete), plus a daily `{"action": "reconcile"}` at 03:30 UTC
**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.

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

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

**Environment vars:** `PROJECT`, `MEMBERSHIPS_TABLE`, `INTERNAL_IDENTITY_PROVIDERS`, `EXTERNAL_IDENTITY_PROVIDERS`

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

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

**Trigger:** Cognito pre-token-generation (V2) on the internal pool (`javabin-internal`), every sign-in and token refresh
**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.
**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.

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

**Environment vars:** `MEMBERSHIPS_TABLE`, `REQUIRED_EMAIL_DOMAIN`, `FEDERATED_IDENTITY_PROVIDERS`

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

Expand Down
77 changes: 65 additions & 12 deletions terraform/lambda-src/app_auth_sync/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
where the app's ECS task picks them up via its team's execution role.
Public clients (browser apps using PKCE) get no secret and no secret parameter.

Each pool has a fixed sign-in IdP list (INTERNAL_IDENTITY_PROVIDERS /
EXTERNAL_IDENTITY_PROVIDERS, comma-separated, set by Terraform). Every client
in the pool gets exactly that list, so with a single IdP the hosted UI sends
the user straight to it (no password form, no sign-up link). If a configured
IdP does not exist in the pool, the request is skipped and logged as an error;
the existing client is left as it is.

The request's allowed groups are written to the auth memberships table as
{pk: "client#<client_id>", allowed_groups: [...]}; the auth-claims trigger
refuses tokens for users in none of them.
Expand All @@ -40,12 +47,27 @@
"internal": os.environ.get("INTERNAL_POOL_NAME", f"{PROJECT}-internal"),
"external": os.environ.get("EXTERNAL_POOL_NAME", f"{PROJECT}-external"),
}

def _idp_list(value):
return [p.strip() for p in value.split(",") if p.strip()]


# Sign-in IdPs per pool. COGNITO means local username/password users.
POOL_IDPS = {
"internal": _idp_list(os.environ.get("INTERNAL_IDENTITY_PROVIDERS", "GoogleSAML")),
"external": _idp_list(os.environ.get("EXTERNAL_IDENTITY_PROVIDERS", "COGNITO")),
}
MEMBERSHIPS_TABLE = os.environ.get("MEMBERSHIPS_TABLE", "")
DEFAULT_SCOPES = ["openid", "email", "profile"]
CLIENT_TYPES = ("confidential", "public")
# Cognito allows plain http only for localhost, for local development
LOCAL_URL = re.compile(r"^http://(localhost|127\.0\.0\.1)(:\d+)?(/.*)?$")
ALLOWED_SCOPES = {"openid", "email", "profile", "phone", "aws.cognito.signin.user.admin"}
# aws.cognito.signin.user.admin lets the signed-in user call UpdateUserAttributes
# with their access token. In the internal pool, auth-claims trusts a federated
# user's email and looks up registry groups by it, so a user who could rewrite
# their own email could take another user's groups.
POOL_FORBIDDEN_SCOPES = {"internal": {"aws.cognito.signin.user.admin"}}

APPS_PREFIX = f"/{PROJECT}/apps/"
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$")
Expand All @@ -59,6 +81,10 @@ class InvalidRequest(ValueError):
pass


class PoolMisconfigured(RuntimeError):
"""The platform side is wrong (not the app's request): skip, keep the client."""


# ---------------------------------------------------------------------------
# Request parsing
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -95,6 +121,9 @@ def urls(key, required):
scopes = req.get("scopes") or DEFAULT_SCOPES
if not isinstance(scopes, list) or not set(scopes) <= ALLOWED_SCOPES:
raise InvalidRequest(f"scopes must be a subset of {sorted(ALLOWED_SCOPES)}")
forbidden = set(scopes) & POOL_FORBIDDEN_SCOPES.get(pool, set())
if forbidden:
raise InvalidRequest(f"scopes {sorted(forbidden)} are not allowed in the {pool} pool")

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


def identity_providers(user_pool_id):
"""Every IdP configured on the pool (e.g. GoogleSAML) plus local users."""
names = []
for page in cognito.get_paginator("list_identity_providers").paginate(UserPoolId=user_pool_id, MaxResults=60):
names += [p["ProviderName"] for p in page["Providers"]]
return names + ["COGNITO"]
_idp_cache = {}


def identity_providers(pool, user_pool_id):
"""The pool's configured sign-in IdPs, after checking each one exists."""
wanted = POOL_IDPS[pool]
if not wanted:
raise PoolMisconfigured(f"no identity providers configured for the {pool} pool")
if user_pool_id not in _idp_cache:
names = set()
for page in cognito.get_paginator("list_identity_providers").paginate(UserPoolId=user_pool_id, MaxResults=60):
names |= {p["ProviderName"] for p in page["Providers"]}
_idp_cache[user_pool_id] = names
existing = _idp_cache[user_pool_id]
missing = [p for p in wanted if p != "COGNITO" and p not in existing]
if missing:
raise PoolMisconfigured(f"{pool} pool has no identity provider {missing}; it has {sorted(existing)}")
return list(wanted)


def client_settings(user_pool_id, client_name, req):
def client_settings(pool, user_pool_id, client_name, req):
return {
"UserPoolId": user_pool_id,
"ClientName": client_name,
Expand All @@ -158,7 +199,7 @@ def client_settings(user_pool_id, client_name, req):
"AllowedOAuthScopes": req["scopes"],
"CallbackURLs": req["callback_urls"],
"LogoutURLs": req["logout_urls"],
"SupportedIdentityProviders": identity_providers(user_pool_id),
"SupportedIdentityProviders": identity_providers(pool, user_pool_id),
"ExplicitAuthFlows": ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"],
"PreventUserExistenceErrors": "ENABLED",
}
Expand Down Expand Up @@ -195,6 +236,10 @@ def _put_param(name, value, secure, team, app):
def sync(team, app, req):
"""Make Cognito match one request. Returns the client ID."""
client_name = f"{team}-{app}"
# Resolve settings first: a misconfigured pool must abort before anything
# (including the pool-switch cleanup below) is changed.
upid = pool_id(req["pool"])
settings = client_settings(req["pool"], upid, client_name, req)

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

upid = pool_id(req["pool"])
settings = client_settings(upid, client_name, req)
want_secret = req["client_type"] == "confidential"
client_id = find_client(upid, client_name)
if client_id:
Expand Down Expand Up @@ -298,7 +341,12 @@ def handle_change(name, operation):
except InvalidRequest as e:
logger.error("Invalid auth request %s: %s", name, e)
return {"status": "invalid", "error": str(e)}
return {"status": "synced", "client": f"{team}-{app}", "client_id": sync(team, app, req)}
try:
client_id = sync(team, app, req)
except PoolMisconfigured as e:
logger.error("Skipped %s-%s, client left unchanged: %s", team, app, e)
return {"status": "error", "error": str(e)}
return {"status": "synced", "client": f"{team}-{app}", "client_id": client_id}


def reconcile():
Expand All @@ -311,14 +359,17 @@ def reconcile():
if owner:
wanted[owner] = p["Value"]

results = {"synced": [], "invalid": [], "deleted": []}
results = {"synced": [], "invalid": [], "error": [], "deleted": []}
for (team, app), raw in sorted(wanted.items()):
try:
sync(team, app, parse_request(raw))
results["synced"].append(f"{team}-{app}")
except InvalidRequest as e:
logger.error("Invalid auth request for %s-%s: %s", team, app, e)
results["invalid"].append(f"{team}-{app}")
except PoolMisconfigured as e:
logger.error("Skipped %s-%s, client left unchanged: %s", team, app, e)
results["error"].append(f"{team}-{app}")

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


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

Expand Down
58 changes: 56 additions & 2 deletions terraform/lambda-src/auth_claims/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
refuse the sign-in, so the app never receives a token.

Clients without an entry, or with an empty list, accept any pool user.

When REQUIRED_EMAIL_DOMAIN is set (internal pool only, e.g. java.no), the
trigger first refuses every user whose email is not @<domain> (case-insensitive)
or whose address is not vouched for: the user must come from one of
FEDERATED_IDENTITY_PROVIDERS (the pool's sign-in IdPs, which only release
Workspace accounts, e.g. GoogleSAML) or have email_verified=true. This is the
server-side guard against self-registered or foreign-IdP users in the internal
pool; the IdP's hosted-domain hint is only a UI hint.
"""

import json
import logging
import os
import time
Expand All @@ -28,6 +37,9 @@

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

dynamodb = boto3.client("dynamodb")

Expand Down Expand Up @@ -64,14 +76,56 @@ def group_members():
return _cache["groups"]


def _masked(email):
"""e***@java.no — enough to correlate in logs without storing the address."""
if "@" not in email:
return "<no email>"
local, domain = email.split("@", 1)
return f"{local[:1]}***@{domain}"


def allowed_groups(client_id):
item = dynamodb.get_item(TableName=TABLE, Key={"pk": {"S": f"client#{client_id}"}}).get("Item")
return set(_strings(item.get("allowed_groups"))) if item else set()


def _providers(event, attrs):
"""Names of the federated IdPs this user signed in through (empty for native users)."""
names = set()
try:
identities = json.loads(attrs.get("identities") or "[]")
except (TypeError, ValueError):
identities = []
if isinstance(identities, list):
names |= {i.get("providerName") for i in identities if isinstance(i, dict) and i.get("providerName")}
# Federated usernames are "<ProviderName>_<id>"; covers events without
# identities. Not spoofable: the pool uses email as the sign-in alias, so
# Cognito assigns native users a UUID username.
user_name = event.get("userName") or ""
names |= {p for p in FEDERATED_IDPS if user_name.startswith(p + "_")}
return names


def domain_allowed(event, attrs, email):
"""True when the pool has no domain rule, or the user satisfies it."""
if not REQUIRED_DOMAIN:
return True
if not email.endswith("@" + REQUIRED_DOMAIN):
return False
if _providers(event, attrs) & FEDERATED_IDPS:
return True
return str(attrs.get("email_verified", "")).lower() == "true"


def handler(event, context):
request = event.get("request", {})
email = (request.get("userAttributes", {}).get("email") or "").lower()
attrs = request.get("userAttributes") or {}
email = (attrs.get("email") or "").lower()

if not domain_allowed(event, attrs, email):
logger.info("Denied %s: not a verified @%s account", _masked(email), REQUIRED_DOMAIN)
raise AccessDenied(f"Sign in with your @{REQUIRED_DOMAIN} account.")

client_id = event.get("callerContext", {}).get("clientId", "")
native = (request.get("groupConfiguration") or {}).get("groupsToOverride") or []

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

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

event["response"] = {
Expand Down
Loading
Loading