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
62 changes: 57 additions & 5 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,69 @@ resources:

### auth

Cognito user pool client registration. Creates app credentials and stores them in SSM at `/{project}/platform-apps/{name}/cognito-{pool}-client-{id,secret}`.
Registers the app as a Cognito client in the platform's internal or external user pool, stores the credentials in SSM, and injects them into the container as environment variables.

Shorthand:

```yaml
auth: internal # uses defaults below
```

Full form:

```yaml
auth: internal # options: internal, external, both, none
auth:
pool: internal # 'internal' or 'external' (see "pool: both" below)
callback_urls: # optional
- https://app.javazone.no/auth/callback
logout_urls: # optional
- https://app.javazone.no/
scopes: # optional, default ['openid', 'email', 'profile']
- openid
- email
- profile
groups: # optional, reference-only — exposed as COGNITO_GROUPS env var
- admins
- editors
```

- `internal` — Javabin members (Google Workspace SSO)
- `external` — public users (self-registration)
- `both` — creates clients in both pools
A client secret is always generated — every app on this platform is a
server-side container.

- `internal` — Javabin members (Google Workspace SSO via SAML)
- `external` — public users (Google OAuth, self-registration)
- `none` or omitted — no Cognito integration

`auth:` requires `routing.host` — callback and logout URL defaults are derived from it.

#### Defaults

If `callback_urls` is omitted, defaults to `[https://{routing.host}/, https://{routing.host}/auth/callback]`.
If `logout_urls` is omitted, defaults to `[https://{routing.host}/]`.

#### Injected environment variables

When `auth:` is set, these env vars are injected into the container automatically:

| Var | Source | Notes |
|---|---|---|
| `COGNITO_USER_POOL_ID` | Looked up from the platform pool | Used to construct the issuer URL on the backend |
| `COGNITO_DOMAIN` | Hosted-UI FQDN of the pool | e.g. `javabin-internal.auth.eu-central-1.amazoncognito.com` (internal); empty for external when no custom domain is configured |
| `COGNITO_ISSUER_URL` | `https://cognito-idp.{region}.amazonaws.com/{pool_id}` | OIDC issuer for JWT validation |
| `COGNITO_CLIENT_ID` | SSM SecureString (via ECS task `secrets`) | The OAuth client ID |
| `COGNITO_CLIENT_SECRET` | SSM SecureString (via ECS task `secrets`) | Always generated — server-side apps only |
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Optional; for app-level authorization on `cognito:groups` JWT claim |

The credentials live in SSM at `/{project}/platform-apps/{name}/cognito-{pool}-client-{id,secret}`. The shared ECS execution role has read access to that path.

#### Groups are reference-only

`auth.groups` is a list of group names that the app *expects* to see on the `cognito:groups` JWT claim. **No Cognito groups are created or modified from `app.yaml`.** Groups themselves are managed by the team-provisioner Lambda (e.g. `team-{team}` groups synced from the registry). If you need a new group, add it via the registry or Cognito console out-of-band, then list it here so the app can read `COGNITO_GROUPS` for authorization checks.

#### `pool: both` is not yet supported

The generator rejects `auth.pool: both`. Apps that need to accept users from both pools should split into two clients with explicit env-var naming — defer this until a concrete need lands.

### domain

Convenience alias for `routing.host`. If both are set, `routing.host` takes precedence.
Expand Down
49 changes: 49 additions & 0 deletions scripts/generate-modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,55 @@ def src(mod):
if queue.get("env"):
env_map[queue["env"]] = f"module.{mod_name}.queue_url"

# Auth (Cognito app client) — optional
auth_raw = app.get("auth")
if auth_raw and auth_raw != "none":
# Accept shorthand: auth: internal -> {pool: internal}
auth = {"pool": auth_raw} if isinstance(auth_raw, str) else auth_raw
pool = auth.get("pool")
if pool == "both":
raise NotImplementedError(
"auth.pool: 'both' is not yet supported — pick 'internal' or 'external'. "
"When the first concrete need lands, decide on COGNITO_INTERNAL_*/COGNITO_EXTERNAL_* env-var conventions."
)
if pool not in ("internal", "external"):
raise ValueError(f"auth.pool must be 'internal' or 'external' (got {pool!r})")

host = routing.get("host")
if not host:
raise ValueError("auth: requires routing.host (callback URLs default to it)")

callback_urls = auth.get("callback_urls") or [
f"https://{host}/",
f"https://{host}/auth/callback",
]
logout_urls = auth.get("logout_urls") or [f"https://{host}/"]

auth_attrs = {
"app_name": name,
"team": team,
"pool": pool,
"callback_urls": callback_urls,
"logout_urls": logout_urls,
"project": PROJECT,
}
if auth.get("scopes"):
auth_attrs["allowed_oauth_scopes"] = auth["scopes"]
if auth.get("external_pool_custom_domain"):
auth_attrs["external_pool_custom_domain"] = auth["external_pool_custom_domain"]

blocks.append(module_block("auth", src("cognito-app-client"), auth_attrs))
policy_map["auth"] = "module.auth.access_policy_json"

env_map["COGNITO_USER_POOL_ID"] = f"module.auth.{pool}_user_pool_id"
env_map["COGNITO_DOMAIN"] = f"module.auth.{pool}_user_pool_domain"
env_map["COGNITO_ISSUER_URL"] = f"module.auth.{pool}_issuer_url"
secret_map["COGNITO_CLIENT_ID"] = f"module.auth.{pool}_client_id_arn"
secret_map["COGNITO_CLIENT_SECRET"] = f"module.auth.{pool}_client_secret_arn"

if auth.get("groups"):
env_map["COGNITO_GROUPS"] = ",".join(auth["groups"])

# Task role (always)
blocks.append(module_block("task_role", src("service-role"), {
"name": name,
Expand Down
23 changes: 12 additions & 11 deletions scripts/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@
# ---------------------------------------------------------------------------

MODULE_SOURCES = {
"platform-data": "terraform/modules/platform-data",
"ecr-repo": "terraform/modules/ecr-repo",
"service-routing": "terraform/modules/service-routing",
"service-role": "terraform/modules/service-role",
"ecs-service": "terraform/modules/ecs-service",
"service-alarm": "terraform/modules/service-alarm",
"service-bucket": "terraform/modules/service-bucket",
"service-database": "terraform/modules/service-database",
"service-rds": "terraform/modules/service-rds",
"service-secret": "terraform/modules/service-secret",
"service-queue": "terraform/modules/service-queue",
"platform-data": "terraform/modules/platform-data",
"ecr-repo": "terraform/modules/ecr-repo",
"service-routing": "terraform/modules/service-routing",
"service-role": "terraform/modules/service-role",
"ecs-service": "terraform/modules/ecs-service",
"service-alarm": "terraform/modules/service-alarm",
"service-bucket": "terraform/modules/service-bucket",
"service-database": "terraform/modules/service-database",
"service-rds": "terraform/modules/service-rds",
"service-secret": "terraform/modules/service-secret",
"service-queue": "terraform/modules/service-queue",
"cognito-app-client": "terraform/modules/cognito-app-client",
}

# ---------------------------------------------------------------------------
Expand Down
84 changes: 64 additions & 20 deletions terraform/modules/cognito-app-client/main.tf
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
################################################################################
# Cognito App Client — registers an app with internal/external user pools
# Cognito App Client — registers an app with internal/external user pools.
#
# Creates Cognito user pool clients and stores credentials in SSM so the
# The module looks up its target pool by NAME (not ID), so callers don't need
# to plumb pool IDs through every layer. Pool names are unique by convention
# in this account (one pool per role: javabin-internal, javabin-external).
#
# Creates an aws_cognito_user_pool_client and stores credentials in SSM so the
# app can read them at runtime without hardcoding.
################################################################################

data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

data "aws_cognito_user_pools" "internal" {
count = local.create_internal ? 1 : 0
name = var.internal_pool_name
}

data "aws_cognito_user_pools" "external" {
count = local.create_external ? 1 : 0
name = var.external_pool_name
}

locals {
create_internal = var.pool == "internal" || var.pool == "both"
create_external = var.pool == "external" || var.pool == "both"

region = data.aws_region.current.region
account_id = data.aws_caller_identity.current.account_id

internal_pool_id = local.create_internal ? tolist(data.aws_cognito_user_pools.internal[0].ids)[0] : ""
external_pool_id = local.create_external ? tolist(data.aws_cognito_user_pools.external[0].ids)[0] : ""

internal_pool_arn = local.create_internal ? "arn:aws:cognito-idp:${local.region}:${local.account_id}:userpool/${local.internal_pool_id}" : ""
external_pool_arn = local.create_external ? "arn:aws:cognito-idp:${local.region}:${local.account_id}:userpool/${local.external_pool_id}" : ""

# Internal pool always has a Cognito-managed domain (deterministic FQDN).
# External pool's domain is custom (auth-external.<domain>) and only exists
# when a certificate is wired up at the platform level — caller passes it in.
internal_pool_domain = local.create_internal ? "${var.internal_pool_name}.auth.${local.region}.amazoncognito.com" : ""
external_pool_domain = local.create_external ? var.external_pool_custom_domain : ""

internal_issuer_url = local.create_internal ? "https://cognito-idp.${local.region}.amazonaws.com/${local.internal_pool_id}" : ""
external_issuer_url = local.create_external ? "https://cognito-idp.${local.region}.amazonaws.com/${local.external_pool_id}" : ""
}

################################################################################
Expand All @@ -17,15 +52,8 @@ locals {
resource "aws_cognito_user_pool_client" "internal" {
count = local.create_internal ? 1 : 0

name = var.app_name
user_pool_id = var.internal_user_pool_id

lifecycle {
precondition {
condition = var.internal_user_pool_id != ""
error_message = "internal_user_pool_id must be set when pool is \"internal\" or \"both\"."
}
}
name = "${var.team}-${var.app_name}"
user_pool_id = local.internal_pool_id

generate_secret = var.generate_secret

Expand Down Expand Up @@ -66,15 +94,8 @@ resource "aws_ssm_parameter" "internal_client_secret" {
resource "aws_cognito_user_pool_client" "external" {
count = local.create_external ? 1 : 0

name = var.app_name
user_pool_id = var.external_user_pool_id

lifecycle {
precondition {
condition = var.external_user_pool_id != ""
error_message = "external_user_pool_id must be set when pool is \"external\" or \"both\"."
}
}
name = "${var.team}-${var.app_name}"
user_pool_id = local.external_pool_id

generate_secret = var.generate_secret

Expand Down Expand Up @@ -107,3 +128,26 @@ resource "aws_ssm_parameter" "external_client_secret" {
type = "SecureString"
value = aws_cognito_user_pool_client.external[0].client_secret
}

################################################################################
# IAM Policy Document — grants ssm:GetParameter[s] over the parameters created.
# Attach to the app's task role via the module's `access_policy_json` output.
################################################################################

locals {
ssm_arns = concat(
local.create_internal ? [aws_ssm_parameter.internal_client_id[0].arn] : [],
local.create_internal && var.generate_secret ? [aws_ssm_parameter.internal_client_secret[0].arn] : [],
local.create_external ? [aws_ssm_parameter.external_client_id[0].arn] : [],
local.create_external && var.generate_secret ? [aws_ssm_parameter.external_client_secret[0].arn] : [],
)
}

data "aws_iam_policy_document" "access" {
statement {
sid = "CognitoSSMRead"
effect = "Allow"
actions = ["ssm:GetParameter", "ssm:GetParameters"]
resources = local.ssm_arns
}
}
86 changes: 79 additions & 7 deletions terraform/modules/cognito-app-client/outputs.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,51 @@
################################################################################
# User pool metadata (looked up by name, exposed for app config)
################################################################################

output "internal_user_pool_id" {
description = "ID of the internal Cognito user pool (empty if pool != internal/both)"
value = local.internal_pool_id
}

output "internal_user_pool_arn" {
description = "ARN of the internal Cognito user pool"
value = local.internal_pool_arn
}

output "internal_user_pool_domain" {
description = "Hosted-UI FQDN for the internal pool (empty if pool != internal/both)"
value = local.internal_pool_domain
}

output "internal_issuer_url" {
description = "OIDC issuer URL for the internal pool — for backend JWT validation"
value = local.internal_issuer_url
}

output "external_user_pool_id" {
description = "ID of the external Cognito user pool (empty if pool != external/both)"
value = local.external_pool_id
}

output "external_user_pool_arn" {
description = "ARN of the external Cognito user pool"
value = local.external_pool_arn
}

output "external_user_pool_domain" {
description = "Custom domain for the external pool (empty when no custom domain configured)"
value = local.external_pool_domain
}

output "external_issuer_url" {
description = "OIDC issuer URL for the external pool"
value = local.external_issuer_url
}

################################################################################
# Client credentials
################################################################################

output "internal_client_id" {
description = "Internal Cognito app client ID (empty if pool != internal/both)"
value = local.create_internal ? aws_cognito_user_pool_client.internal[0].id : ""
Expand All @@ -8,12 +56,36 @@ output "external_client_id" {
value = local.create_external ? aws_cognito_user_pool_client.external[0].id : ""
}

output "internal_client_id_arn" {
description = "ARN of the SSM parameter holding the internal client ID (empty when pool != internal/both)"
value = local.create_internal ? aws_ssm_parameter.internal_client_id[0].arn : ""
}

output "internal_client_secret_arn" {
description = "ARN of the SSM parameter holding the internal client secret (empty when not generated)"
value = local.create_internal && var.generate_secret ? aws_ssm_parameter.internal_client_secret[0].arn : ""
}

output "external_client_id_arn" {
description = "ARN of the SSM parameter holding the external client ID (empty when pool != external/both)"
value = local.create_external ? aws_ssm_parameter.external_client_id[0].arn : ""
}

output "external_client_secret_arn" {
description = "ARN of the SSM parameter holding the external client secret (empty when not generated)"
value = local.create_external && var.generate_secret ? aws_ssm_parameter.external_client_secret[0].arn : ""
}

################################################################################
# IAM
################################################################################

output "ssm_parameter_arns" {
description = "ARNs of all SSM parameters created (for IAM policy scoping)"
value = concat(
local.create_internal ? [aws_ssm_parameter.internal_client_id[0].arn] : [],
local.create_internal && var.generate_secret ? [aws_ssm_parameter.internal_client_secret[0].arn] : [],
local.create_external ? [aws_ssm_parameter.external_client_id[0].arn] : [],
local.create_external && var.generate_secret ? [aws_ssm_parameter.external_client_secret[0].arn] : [],
)
description = "All SSM parameters created — kept for callers that want the bag (IAM policy scoping, etc.)"
value = local.ssm_arns
}

output "access_policy_json" {
description = "IAM policy JSON granting ssm:GetParameter[s] over the cognito SSM params — attach to task role"
value = data.aws_iam_policy_document.access.json
}
Loading