|
| 1 | +# ADR-0009: Akrites → CDP public API authentication |
| 2 | + |
| 3 | +**Date**: 2026-07-15 |
| 4 | +**Status**: proposed |
| 5 | +**Deciders**: CDP team, LF Auth, Akrites team |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Akrites is a new upstream consumer that needs read-only access to CDP's public |
| 10 | +API. LF Auth provisions LF-managed M2M credentials using RSA keypair |
| 11 | +authentication: Akrites signs a JWT `client_assertion` with a vault-resident |
| 12 | +private key, exchanges it at LFX Auth0 for a short-lived Bearer token, then |
| 13 | +calls CDP. CDP verifies the token, enforces a single-consumer restriction, and |
| 14 | +gates endpoints on a dedicated scope. |
| 15 | + |
| 16 | +The Auth0 audience (resource-server identifier) is not yet finalized — it is |
| 17 | +referenced throughout as `{{AKRITES_CDP_AUDIENCE}}`. |
| 18 | + |
| 19 | +High-level overview: https://linuxfoundation-dxwx.dsp.so/fkNtkFCx-akrites-cdp-auth |
| 20 | + |
| 21 | +## Decision |
| 22 | + |
| 23 | +Authenticate Akrites via **LFX Auth0 with a dedicated resource server and |
| 24 | +required scopes**. On the CDP side, add an `azp` allowlist middleware |
| 25 | +after JWT verification that asserts the token was issued specifically to |
| 26 | +`{{AKRITES_AUTH0_CLIENT_ID}}`, so no other M2M client granted the same |
| 27 | +audience can reach the `/akrites` router. |
| 28 | + |
| 29 | +## Auth Flow |
| 30 | + |
| 31 | +```mermaid |
| 32 | +sequenceDiagram |
| 33 | + participant Vault as LF Vault |
| 34 | + participant Akrites |
| 35 | + participant Auth0 as LFX Auth0 |
| 36 | + participant CDP |
| 37 | +
|
| 38 | + Note over Akrites,Vault: On startup or after rotation |
| 39 | + Akrites->>Vault: read RSA private key |
| 40 | + Vault-->>Akrites: private key |
| 41 | +
|
| 42 | + Note over Akrites,Auth0: Token exchange (repeated on expiry) |
| 43 | + Akrites->>Akrites: sign client_assertion JWT (RS256) |
| 44 | + Akrites->>Auth0: POST /oauth/token (client_credentials + jwt-bearer) |
| 45 | + Auth0-->>Akrites: Bearer access_token (scope=read:<resource>) |
| 46 | +
|
| 47 | + Note over Akrites,CDP: API call |
| 48 | + Akrites->>CDP: GET /public/v1/akrites/* + Bearer token |
| 49 | + CDP->>Auth0: fetch JWKS (cached) |
| 50 | + CDP->>CDP: oauth2Middleware: verify sig + iss + aud |
| 51 | + CDP->>CDP: azpAllowlistMiddleware: assert azp == AKRITES_CLIENT_ID |
| 52 | + CDP->>CDP: requireScopes: assert read:<resource> |
| 53 | + CDP-->>Akrites: 200 OK |
| 54 | +
|
| 55 | + Note over Akrites,Auth0: On invalid_client (key rotated by LF) |
| 56 | + Akrites->>Vault: re-read private key |
| 57 | + Akrites->>Auth0: retry token exchange once |
| 58 | +``` |
| 59 | + |
| 60 | +## Affected Repositories |
| 61 | + |
| 62 | +### `auth0-terraform` |
| 63 | + |
| 64 | +Three changes. The existing `auth0_resource_server.cdp_public_api` (lines 400–494 |
| 65 | +of `resource_servers.tf`) and `grants_cdp.tf` are the direct reference — Akrites |
| 66 | +gets its own isolated resource server, not a grant on the shared CDP one. |
| 67 | + |
| 68 | +**`resource_servers.tf`** — add these two blocks: |
| 69 | +```hcl |
| 70 | +resource "auth0_resource_server" "cdp_akrites_api" { |
| 71 | + name = "CDP Akrites API" |
| 72 | + identifier = { |
| 73 | + "dev" = "{{AKRITES_CDP_AUDIENCE_DEV}}" |
| 74 | + "staging" = "{{AKRITES_CDP_AUDIENCE_STAGING}}" |
| 75 | + "prod" = "{{AKRITES_CDP_AUDIENCE_PROD}}" |
| 76 | + }[terraform.workspace] |
| 77 | +
|
| 78 | + signing_alg = "RS256" |
| 79 | + token_lifetime = { |
| 80 | + "dev" = 3600 |
| 81 | + "staging" = 10800 |
| 82 | + "prod" = 10800 |
| 83 | + }[terraform.workspace] |
| 84 | + token_dialect = "access_token" |
| 85 | + allow_offline_access = false |
| 86 | +
|
| 87 | + subject_type_authorization { |
| 88 | + user { policy = "deny_all" } |
| 89 | + client { policy = "require_client_grant" } |
| 90 | + } |
| 91 | +} |
| 92 | +
|
| 93 | +resource "auth0_resource_server_scopes" "cdp_akrites_api" { |
| 94 | + resource_server_identifier = auth0_resource_server.cdp_akrites_api.identifier |
| 95 | +
|
| 96 | + scopes { |
| 97 | + name = "read:<resource>" |
| 98 | + description = "Read-only access to CDP data via the Akrites API" |
| 99 | + } |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +**`clients_m2m.tf`** — add one entry to `local.m2m_clients` (line ~10): |
| 104 | +```hcl |
| 105 | +"Akrites" = { oidc_conformant = true }, |
| 106 | +``` |
| 107 | +The existing `auth0_client.m2m_clients` `for_each` resource instantiates it |
| 108 | +automatically with `grant_types = ["client_credentials"]`. Auth method starts |
| 109 | +as `client_secret_post`; `lfx-secrets-management` rotation converts it to |
| 110 | +Private Key JWT — same as all other M2M clients. |
| 111 | + |
| 112 | +**`grants_akrites.tf`** _(new file)_ |
| 113 | +```hcl |
| 114 | +resource "auth0_client_grant" "akrites_cdp" { |
| 115 | + client_id = auth0_client.m2m_clients["Akrites"].id |
| 116 | + audience = auth0_resource_server.cdp_akrites_api.identifier |
| 117 | + scopes = ["read:<resource>"] |
| 118 | + depends_on = [auth0_resource_server_scopes.cdp_akrites_api] |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +### `lfx-secrets-management` |
| 125 | + |
| 126 | +No structural changes. Add Akrites as a new entry in the sync config: |
| 127 | + |
| 128 | +- **Source**: Auth0 — uses existing `Auth0JWTConfig` model |
| 129 | + (`secretsmanagement/services/auth0.py`) |
| 130 | +- **Destinations**: |
| 131 | + - AWS Secrets Manager (prod) — `secretsmanagement/services/aws.py` via `boto3` |
| 132 | + - 1Password (dev) — `secretsmanagement/services/onepassword.py` via `op` CLI |
| 133 | +- **Orchestration**: `secretsmanagement/sync.py` lines 37–96 — Akrites follows |
| 134 | + same source → destination pattern as all other M2M clients |
| 135 | + |
| 136 | +CDP holds no private key. Token verification is JWKS-only. |
| 137 | + |
| 138 | +--- |
| 139 | + |
| 140 | +### `crowd.dev` (CDP — this repo) |
| 141 | + |
| 142 | +**`backend/config/custom-environment-variables.json`** (currently lines 161–166) |
| 143 | + |
| 144 | +Add alongside the existing `auth0` block: |
| 145 | +```json |
| 146 | +"auth0Akrites": { |
| 147 | + "issuerBaseURLs": "CROWD_AUTH0_AKRITES_ISSUER_BASE_URLS", |
| 148 | + "audience": "CROWD_AUTH0_AKRITES_AUDIENCE", |
| 149 | + "clientId": "CROWD_AUTH0_AKRITES_CLIENT_ID" |
| 150 | +} |
| 151 | +``` |
| 152 | + |
| 153 | +**`backend/src/conf/index.ts`** (line 106) |
| 154 | + |
| 155 | +Add: |
| 156 | +```ts |
| 157 | +export const AKRITES_AUTH0_CONFIG: Auth0Configuration = config.get<Auth0Configuration>('auth0Akrites') |
| 158 | +``` |
| 159 | +`Auth0Configuration` interface (`backend/src/conf/configTypes.ts` lines 68–73) is reused as-is. |
| 160 | + |
| 161 | +**`backend/src/security/scopes.ts`** |
| 162 | + |
| 163 | +Add to `SCOPES` const: |
| 164 | +```ts |
| 165 | +READ_RESOURCE: 'read:<resource>', |
| 166 | +``` |
| 167 | + |
| 168 | +**`backend/src/api/public/middlewares/azpAllowlistMiddleware.ts`** _(new file)_ |
| 169 | + |
| 170 | +Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if not in the |
| 171 | +configured allowlist. Fails closed — any unknown client ID is rejected even if |
| 172 | +Auth0 is misconfigured to grant it the same audience. |
| 173 | + |
| 174 | +**`backend/src/api/public/v1/index.ts`** (line 44) |
| 175 | + |
| 176 | +Replace: |
| 177 | +```ts |
| 178 | +router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) |
| 179 | +``` |
| 180 | +With: |
| 181 | +```ts |
| 182 | +router.use( |
| 183 | + '/akrites', |
| 184 | + oauth2Middleware(AKRITES_AUTH0_CONFIG), |
| 185 | + azpAllowlistMiddleware([AKRITES_AUTH0_CONFIG.clientId]), |
| 186 | + requireScopes([SCOPES.READ_AKRITES], 'all'), |
| 187 | + akritesRouter(), |
| 188 | +) |
| 189 | +``` |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +### Akrites (external repo) |
| 194 | + |
| 195 | +Implement the token exchange described in the Auth Flow diagram above: |
| 196 | + |
| 197 | +1. Read RSA private key from vault (AWS Secrets Manager in prod via |
| 198 | + `lfx-secrets-management`, 1Password in dev). |
| 199 | +2. Build and sign `client_assertion` JWT (RS256). |
| 200 | +3. POST to Auth0 `/oauth/token` — cache the returned Bearer token until it |
| 201 | + expires. |
| 202 | +4. Attach Bearer token to all CDP requests via `Authorization` header. |
| 203 | +5. On `invalid_client`: discard cached key → re-read from vault → retry token |
| 204 | + exchange once. LF rotates the keypair without notice. |
0 commit comments