Skip to content

Commit b12cd18

Browse files
kjgbotclaude
andcommitted
feat(scripts): mint-api-key.sh + docs/operations/api-key-minting.md
Adds a wrapper around the admin-bearer + POST /v1/api-keys + secret-store flow that's been bitten three times today: 1. Picking the wrong scope set silently succeeds at mint, fails at runtime — sage's SAGE_RELAYAUTH_API_KEY had scopes ["cloud:specialist:invoke"] but actually needed ["relayauth:identity:create:*", "relayauth:token:create:*", "relayfile:fs:read:*", "relayfile:fs:write:*"] for its mintRelayfileToken path. Surfaced as repeated 403 insufficient_scope from production once the apiKeyAuth Workers fix landed. 2. Echoing the new key value to terminal/shell history before piping into gh secret set leaks the credential. 3. Hand-typing the request body shape per service caller invites typos that aren't caught until runtime. The script: - generates the admin bearer using the existing generate-dev-token.sh helper (HS256, signed with SIGNING_KEY) - POSTs to /v1/api-keys with the operator's name + scopes - prints the new api-key id + scopes (safe), never the value - pipes the value directly into one of {gh secret set | file with mode 600 | --print-key for explicit piping | mktemp fallback} - optionally revokes a prior api-key id for clean rotation - scrubs ADMIN_BEARER + the key value from env on exit Doc at docs/operations/api-key-minting.md captures: - canonical sage rotation example (the actual scope set that works) - per-service required scopes (sage, specialist-worker, operator admin) - operator emergency admin key recipe (provision before phase-122 step 3 sunsets HS256 admin bearers) - explanation of why scope-subset enforcement matters - recovery procedure when the value was lost mid-mint Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1ddc99b commit b12cd18

2 files changed

Lines changed: 404 additions & 0 deletions

File tree

docs/operations/api-key-minting.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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

Comments
 (0)