Files

216 lines
9.4 KiB
Markdown
Raw Permalink Normal View History

# Two-Pool Service Accounts (Cognito)
Reference for the multi-issuer authentication path introduced by the
`api.key.support.cognito.two-pool.plan.3.claude.md` plan. Human users
keep authenticating through the existing Cognito user pool ("pool A");
machine callers authenticate through a second user pool ("pool B")
that has MFA disabled. queryAPI accepts JWTs from either pool with no
other code changes for the consuming caller.
This page covers what changed in queryAPI, the operator-side setup,
the lifecycle CLI, and what to verify when something breaks.
## What the feature changes
| Surface | Pool A (humans) | Pool B (service accounts) |
| --- | --- | --- |
| Cognito user pool | unchanged (`COGNITO_USER_POOL_ID`) | new (`COGNITO_SVC_USER_POOL_ID`) |
| JWKS | `COGNITO_JWKS_URL` (derived) | `COGNITO_SVC_JWKS_URL` (derived) |
| Caller flow | OAuth + PKCE + MFA via hosted UI | `InitiateAuth` USER_PASSWORD_AUTH |
| `iss` claim | `https://cognito-idp.<region>.amazonaws.com/<pool-A-id>` | `.../<pool-B-id>` |
| `auth_source` (echo ctx) | `"user"` | `"service"` |
| Env check (`CheckUserEnvironmentAccess`) | looks up `sub` in pool A | looks up `sub` in pool B |
| EULA agreement | required on first login | not applicable (service accounts cannot interactively agree) |
| Permit.io user | keyed on pool A `sub`, attribute `is_service_account` absent | keyed on pool B `sub`, attribute `is_service_account=true` |
| Lifecycle | `cmd/auth_related/user.creation.tool` | `cmd/auth_related/service.account.tool` |
The feature is **off by default**. With `COGNITO_SVC_USER_POOL_ID`
empty, queryAPI rebuilds the keyset map with only pool A and the
runtime behavior is bit-identical to pre-change.
## Configuration
Set these in `.env` (or the equivalent secret store) when enabling
pool B for an environment:
```dotenv
# Required to turn the feature on. queryAPI derives the JWKS URL from
# this id at startup. The CLI tool refuses to run when this is empty.
COGNITO_SVC_USER_POOL_ID=us-east-2_Qom1i9qIG
# Optional. Defaults to the cognito-idp.<region>.amazonaws.com URL
# computed from COGNITO_SVC_USER_POOL_ID + COGNITO_REGION. Override
# only for tests that point at a fake JWKS endpoint.
COGNITO_SVC_JWKS_URL=
# Only the CLI tool and consuming callers need this. queryAPI itself
# does not call InitiateAuth — it only validates tokens — so it is
# safe to omit on queryAPI hosts.
COGNITO_SVC_APP_CLIENT_ID=6r7chkvjotgs2bsfbd5ibaaft7
```
The full template is in `.env.example`. The provisioning scripts in
`cmd/auth_related/service.accounts/creation.scripts/` print the
correct values for the dev environment.
## Caller flow
A consuming process needs `(username, password, app-client-id)` plus
the pool id. It does not need AWS IAM credentials — `InitiateAuth` is
an unauthenticated Cognito endpoint.
```bash
# 1. Get an access token. Refresh ~10 min before exp.
aws cognito-idp initiate-auth \
--region "$COGNITO_REGION" \
--client-id "$COGNITO_SVC_APP_CLIENT_ID" \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME="$SVC_USERNAME",PASSWORD="$SVC_PASSWORD"
# 2. Call queryAPI with the access token. The token shape is
# identical to a human token: sub, iss, username, exp, ...
curl https://queryapi.example.com/clients \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 3. When near expiry, refresh.
aws cognito-idp initiate-auth \
--region "$COGNITO_REGION" \
--client-id "$COGNITO_SVC_APP_CLIENT_ID" \
--auth-flow REFRESH_TOKEN_AUTH \
--auth-parameters REFRESH_TOKEN="$REFRESH_TOKEN"
```
For a zero-dependency reference implementation in Python, see
`cmd/auth_related/service.accounts/test.scripts/test.service.account.auth.py`.
## How queryAPI authenticates two issuers safely
`internal/cognitoauth/token.go` builds an `IssuerKeysets` map at the
start of each verifier call: pool A always present, pool B added when
`COGNITO_SVC_USER_POOL_ID` is set. Verification is a two-pass:
1. Peek at the token's `iss` claim **without** trusting it (no
signature check). The peek's only job is to pick a keyset.
2. Re-parse the token with `jwt.WithKeySet(keySet)` and
`jwt.WithIssuer(iss)`. A forged `iss` therefore fails because the
chosen pool's JWKS does not contain the signer's key.
Anything whose `iss` is not in the map is rejected with
`"untrusted issuer: ..."`. The JWKS cache (`internal/cognitoauth/jwks.go`)
is keyed by URL, so the two pools never clobber each other's
cached keys.
## Service-account lifecycle CLI
`cmd/auth_related/service.account.tool` wraps the Cognito + Permit
calls needed to manage pool B users from an operator workstation.
Source layout mirrors `user.creation.tool`.
Subcommands:
```text
service.account.tool create <username> [--description text] [--env-id id] [--roles r1,r2]
service.account.tool show <username>
service.account.tool list
service.account.tool disable <username> # AdminDisableUser + AdminUserGlobalSignOut
service.account.tool enable <username> # AdminEnableUser
service.account.tool rotate-password <username> # AdminSetUserPassword (permanent)
service.account.tool delete <username> # AdminDeleteUser + DeletePermitUser
service.account.tool register-permit <username> [--roles r1,r2]
# Registers an already-existing Cognito service-account in
# Permit and assigns roles. Use this when the Cognito user was
# created outside the tool (e.g. by the bring-up shell scripts
# in cmd/auth_related/service.accounts/creation.scripts/).
```
Env vars (the wrapper `run.tool.sh` sets the dev defaults):
```dotenv
COGNITO_REGION=us-east-2
COGNITO_SVC_USER_POOL_ID=us-east-2_Qom1i9qIG
COGNITO_SVC_APP_CLIENT_ID=6r7chkvjotgs2bsfbd5ibaaft7
PERMIT_IO_API_KEY=...
PERMIT_IO_BASE_URL=https://api.permit.io
AWS_PROFILE=...
```
The `create` command:
- prints the password **once**;
- stores nothing local (you must copy it into your secret store);
- registers the user in Permit with `is_service_account=true`;
- assigns any roles passed via `--roles`.
If `PERMIT_IO_API_KEY` is unset, the create flow logs a warning and
skips the Permit step — useful for offline dry-runs but not what you
want in production.
## EULA
queryAPI does not currently enforce EULA acceptance with a middleware
gate — the EULA endpoints (`/eula/agree`, `/eula/status`) are
self-service and surface status. Service accounts simply do not call
them. If a future change introduces an EULA-enforcement middleware,
the right hook is `auth_source == "service"` → skip; the value is
already on the echo context by then.
## Revocation
Cognito does not provide instant revocation for outstanding access
tokens. The mitigation is two layers:
1. Bound the lag with the pool B `AccessTokenValidity` setting
(60 minutes by default; can drop to 5 minutes per the plan).
2. Operator runs `service.account.tool disable <username>`:
- `AdminDisableUser` blocks new auth + refresh attempts.
- `AdminUserGlobalSignOut` revokes all refresh tokens.
- Outstanding access tokens remain valid until `exp`.
If sub-minute revocation is ever required, the follow-up is an
in-service deny list keyed on `sub` for `auth_source == "service"`.
It is deliberately not in scope for v1.
## Testing locally
Unit tests covering the multi-issuer paths live in:
- `internal/cognitoauth/multi_issuer_test.go`
(verifier accepts pool A + pool B tokens, rejects forged `iss`, rejects unknown issuers)
- `internal/cognitoauth/envcheck_test.go`
(`CheckUserEnvironmentAccess` dispatches on `auth_source`; rejects
when `auth_source=service` but pool B is unconfigured)
- `cmd/auth_related/service.account.tool/main_test.go`
(password class invariants, CSV splitter)
Run the targeted set with:
```bash
go test ./internal/cognitoauth/ -run TwoPool
go test ./cmd/auth_related/service.account.tool/
```
For an end-to-end smoke test against the dev pool B, see
`cmd/auth_related/service.accounts/test.scripts/test.service.account.auth.py`.
Lifecycle coverage (disable / enable / rotate-password / delete) lives
in `scripts/manual_tests/test_two_pool_lifecycle.py`. It uses a
throwaway user (`SVC_LIFECYCLE_USERNAME`, default
`svc-lifecycle-test-1`) so it never collides with the long-lived
super-admin test account.
Both manual scripts read credentials from the env vars documented in
`.env.example` under "Pool B (service-accounts) test credentials"
(`SVC_TEST_USERNAME`, `SVC_TEST_PASSWORD`, `SVC_TEST_SUB`,
`SVC_TEST_USER_POOL_ID`, `SVC_TEST_APP_CLIENT_ID`). A repo-root
`.env` is auto-loaded; no third-party `dotenv` library is required.
## When something breaks
| Symptom | Likely cause | Where to look |
| --- | --- | --- |
| All requests return 401 right after enabling pool B | Wrong `COGNITO_SVC_JWKS_URL` or pool B JWKS endpoint unreachable | `loadIssuerKeysets` in `middleware.go`; check server startup logs for `"loading service pool JWKS"` |
| Service token returns 403 from env check | Pool B `sub` not registered in Permit.io with the right `environment_id` and roles | `service.account.tool register-permit <username>` |
| `"untrusted issuer"` warnings | Token issued by a Cognito pool that is not configured | Verify the token's `iss` matches one of the two configured pools |
| `"untrusted issuer"` for a known pool right after rollout | Pool B JWKS not loaded because `COGNITO_SVC_USER_POOL_ID` is empty | Confirm env var presence with a `printenv \| grep COGNITO_SVC` |
| EULA endpoint returns "Insufficient permissions" for service token | Service account does not have a role with `eula` action permission | Either grant the role in Permit, or do not call EULA endpoints from service code |