|
| 1 | +# API key minting & rotation |
| 2 | + |
| 3 | +How to provision and rotate `@relayauth/server` api-keys for service callers |
| 4 | +(sage, specialist-worker, future agents). Use `scripts/mint-api-key.sh` |
| 5 | +rather than hand-rolling curl — the script wraps three things that have |
| 6 | +bitten operators before: |
| 7 | + |
| 8 | +1. Minting the admin bearer (HS256, signed locally) with the right shape |
| 9 | +2. Calling `POST /v1/api-keys` with a body the server will accept |
| 10 | +3. Storing the returned key value in a secret store **without** echoing it |
| 11 | + into terminal/shell history |
| 12 | + |
| 13 | +## Quick start: rotate sage's relayfile-minter api-key |
| 14 | + |
| 15 | +This is the canonical example because it's what triggered the script's |
| 16 | +existence (incident on 2026-04-23 — sage's api-key had `cloud:specialist:invoke` |
| 17 | +which is the wrong scope set entirely). |
| 18 | + |
| 19 | +```bash |
| 20 | +./scripts/mint-api-key.sh \ |
| 21 | + --name sage-relayfile-minter \ |
| 22 | + --scopes-json '[ |
| 23 | + "relayauth:identity:create:*", |
| 24 | + "relayauth:token:create:*", |
| 25 | + "relayfile:fs:read:*", |
| 26 | + "relayfile:fs:write:*" |
| 27 | + ]' \ |
| 28 | + --to-gh-secret AgentWorkforce/cloud:SAGE_RELAYAUTH_API_KEY \ |
| 29 | + --revoke-prior <previous-api-key-id> |
| 30 | +``` |
| 31 | + |
| 32 | +The script prints the new api-key id + scopes (safe to share/log), pipes |
| 33 | +the key value directly into `gh secret set` (never visible to the |
| 34 | +operator), and revokes the previous key after the new one is in place. |
| 35 | +Then trigger a cloud deploy so the sage worker picks up the new |
| 36 | +`SAGE_RELAYAUTH_API_KEY` value: |
| 37 | + |
| 38 | +```bash |
| 39 | +gh workflow run deploy.yml --repo AgentWorkforce/cloud --ref main |
| 40 | +``` |
| 41 | + |
| 42 | +## Required scopes by service |
| 43 | + |
| 44 | +Each service caller has different needs. Don't copy-paste from the wrong |
| 45 | +example — the server enforces per-route scope requirements; an api-key |
| 46 | +with the wrong set succeeds at mint but fails at runtime with |
| 47 | +`insufficient_scope`, which is a hard 403 from the route handler. |
| 48 | + |
| 49 | +### sage (Slack DM handler → mints relayfile tokens for itself) |
| 50 | + |
| 51 | +`packages/sage/src/integrations/relayfile-jwt.ts` calls: |
| 52 | + |
| 53 | +- `POST /v1/identities` to spawn a child identity per workspace |
| 54 | +- `POST /v1/tokens` to mint an access token for that identity, scoped to |
| 55 | + relayfile read/write |
| 56 | + |
| 57 | +So sage's api-key needs: |
| 58 | + |
| 59 | +```json |
| 60 | +[ |
| 61 | + "relayauth:identity:create:*", |
| 62 | + "relayauth:token:create:*", |
| 63 | + "relayfile:fs:read:*", |
| 64 | + "relayfile:fs:write:*" |
| 65 | +] |
| 66 | +``` |
| 67 | + |
| 68 | +The relayfile scopes are required because the new identity sage creates |
| 69 | +inherits its own scopes from its sponsor's scope set (the api-key's |
| 70 | +synthesized claims). Without `relayfile:fs:*`, the minted token would |
| 71 | +have no fs permissions even though sage successfully created the identity. |
| 72 | + |
| 73 | +### specialist-worker (verifies sage's bearer; doesn't itself mint) |
| 74 | + |
| 75 | +Specialist-worker only **verifies** incoming bearer tokens via |
| 76 | +`@relayauth/sdk`'s `TokenVerifier`. It does not call `/v1/api-keys` or |
| 77 | +`/v1/tokens`. It does not need an api-key. |
| 78 | + |
| 79 | +What specialist-worker needs is the JWKS to be reachable (production: |
| 80 | +`https://api.relayauth.dev/.well-known/jwks.json`) and, post-phase-122 |
| 81 | +sunset, the env binding `RELAYAUTH_VERIFIER_ACCEPT_HS256=false`. |
| 82 | + |
| 83 | +### Operator emergency admin api-key |
| 84 | + |
| 85 | +Worth provisioning one before phase 122 step 3 sunsets HS256 admin bearers |
| 86 | +so you have a way to make admin calls (rotate keys, revoke compromised |
| 87 | +identities, etc.) without an HS256-signed dev token. Stash the value |
| 88 | +somewhere outside this repo (1Password, a `~/.relayauth-admin-key` file |
| 89 | +with mode 600). |
| 90 | + |
| 91 | +```bash |
| 92 | +./scripts/mint-api-key.sh \ |
| 93 | + --name operator-admin-emergency \ |
| 94 | + --scopes-json '["*:*:*:*"]' \ |
| 95 | + --to-file ~/.relayauth-admin-key |
| 96 | +``` |
| 97 | + |
| 98 | +Then any admin operation, post-sunset: |
| 99 | + |
| 100 | +```bash |
| 101 | +curl -sS -X POST https://api.relayauth.dev/v1/api-keys \ |
| 102 | + -H "x-api-key: $(cat ~/.relayauth-admin-key)" \ |
| 103 | + -H "content-type: application/json" \ |
| 104 | + -d '{...}' |
| 105 | +``` |
| 106 | + |
| 107 | +## How the script protects the secret value |
| 108 | + |
| 109 | +By default, the api-key value never appears in: |
| 110 | + |
| 111 | +- terminal stdout (the script prints id + scopes, not the key) |
| 112 | +- shell history (no `echo` or `export`-and-print of the value) |
| 113 | +- log files |
| 114 | + |
| 115 | +The supported destinations: |
| 116 | + |
| 117 | +| Flag | Storage | Notes | |
| 118 | +|---|---|---| |
| 119 | +| `--to-gh-secret REPO:NAME` | GitHub Actions secret | Piped directly into `gh secret set` via stdin. The value is never assigned to a shell variable. Recommended for sage / cloud services. | |
| 120 | +| `--to-file PATH` | Local file (mode 600) | For operator emergency keys you need on your laptop. Write to `~/.something`, never to `/tmp` (other processes can read /tmp). | |
| 121 | +| `--print-key` | stdout | Only when you're **explicitly** piping to another process. The value will appear in shell history if you run the script directly without piping. | |
| 122 | +| (none) | mktemp tempfile, mode 600 | Fallback when the operator forgot to pick a destination. Path is printed to stderr; operator should copy out and `shred -u` immediately. | |
| 123 | + |
| 124 | +## Why scope-subset matters |
| 125 | + |
| 126 | +`POST /v1/api-keys` enforces that the new api-key's `scopes` are a subset |
| 127 | +of the *caller's* scopes. The caller is the holder of the admin bearer |
| 128 | +that authorizes the mint call. Practical implication: |
| 129 | + |
| 130 | +- The script generates an admin bearer with `["*:*:*:*"]` — wildcard |
| 131 | + superset — so you can mint any narrower scope set. |
| 132 | +- An api-key minted with narrower scopes (e.g. only `relayfile:fs:read:*`) |
| 133 | + CANNOT be used to mint a broader api-key. Privilege escalation is |
| 134 | + blocked at this gate. |
| 135 | +- If you rotate by issuing a new api-key with the same scopes the old |
| 136 | + one had, no escalation; you're just refreshing the secret value. |
| 137 | + |
| 138 | +## Rotation cadence |
| 139 | + |
| 140 | +Per the spec (`specs/token-format.md`), production keys rotate on a 90-day |
| 141 | +cycle. This script doesn't enforce or schedule that — set up a calendar |
| 142 | +reminder or a CI job that runs the script with `--revoke-prior` set to |
| 143 | +the current key id. |
| 144 | + |
| 145 | +## Recovery: I lost the key value |
| 146 | + |
| 147 | +The api-key value is returned exactly once at creation time. If the |
| 148 | +storage step (gh secret set / file write) failed and you didn't capture |
| 149 | +the value, you cannot retrieve it. The fix: |
| 150 | + |
| 151 | +1. Revoke the api-key whose value is unknown: |
| 152 | + ```bash |
| 153 | + curl -X POST "$RELAYAUTH_URL/v1/api-keys/<id>/revoke" \ |
| 154 | + -H "Authorization: Bearer <admin>" |
| 155 | + ``` |
| 156 | +2. Re-run the mint with the same `--name` and `--scopes-json` to issue |
| 157 | + a fresh value. |
| 158 | + |
| 159 | +The id is always visible (printed by the script + listed via `GET |
| 160 | +/v1/api-keys`); only the value is one-shot. |
| 161 | + |
| 162 | +## Related |
| 163 | + |
| 164 | +- Spec: `specs/api-keys-and-rs256-migration.md` (phase 1) |
| 165 | +- Issue documenting the sage scope incident: AgentWorkforce/cloud#287 |
| 166 | + (preview/dev) and the comment on cloud#295 (post-cutover verification) |
| 167 | +- Source of truth for what scopes each route requires: |
| 168 | + `packages/server/src/routes/*.ts` — search for the third arg to |
| 169 | + `authenticateAndAuthorize` / `authenticateAndAuthorizeFromContext` |
0 commit comments