|
|
|
@@ -0,0 +1,215 @@
|
|
|
|
|
# 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 |
|