Skip to content

Commit 8fc87d4

Browse files
musingfoxclaude
andcommitted
spiral(turn 27): setup-dovecote action OIDC mode + T26 cosmetic cleanup
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 34d5d52 commit 8fc87d4

5 files changed

Lines changed: 379 additions & 7 deletions

File tree

.github/actions/setup-dovecote/action.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
name: "Setup dovecote"
22
description: "Install the dovecote CLI and verify checksum + reachability."
3+
# Usage notes:
4+
# Legacy mode: provide `token` input — the token is exported directly, no HTTP exchange.
5+
# OIDC mode: provide `oidc-audience` (and leave `token` empty); `server-url` is required.
6+
# The job must declare `permissions: id-token: write`.
7+
# Constraint-4: place this step immediately before first dovecote CLI usage,
8+
# not at job start, to minimise the live-token window.
9+
# Mutual exclusion: setting both `token` and `oidc-audience` is an error.
310

411
inputs:
512
version:
613
description: "Release version to install (e.g., 0.1.0)."
714
required: true
815
token:
9-
description: "DOVECOTE_TOKEN to export into env. If unset, the action leaves the caller's DOVECOTE_TOKEN env var (if any) untouched."
16+
description: "DOVECOTE_TOKEN to export into env (legacy mode). If unset, the action leaves the caller's DOVECOTE_TOKEN env var (if any) untouched, unless oidc-audience is provided."
17+
required: false
18+
oidc-audience:
19+
description: "Audience for GitHub Actions OIDC exchange. When set (and token is empty), the action exchanges a GitHub OIDC id_token for a dovecote token. server-url is implicitly required in this mode."
1020
required: false
1121
server-url:
12-
description: "Override DOVECOTE_SERVER_URL."
22+
description: "Override DOVECOTE_SERVER_URL. Required when using oidc-audience."
1323
required: false
1424
repository:
1525
description: "GitHub repo (owner/name) hosting the release. Defaults to the action's repo."
@@ -102,13 +112,30 @@ runs:
102112
- name: Export env
103113
shell: bash
104114
run: |
115+
# Mutual exclusion: token and oidc-audience must not both be provided.
116+
if [ -n "${{ inputs.token }}" ] && [ -n "${{ inputs.oidc-audience }}" ]; then
117+
echo "setup-dovecote: cannot set both 'token' and 'oidc-audience' — choose one mode." >&2
118+
exit 1
119+
fi
105120
if [ -n "${{ inputs.token }}" ]; then
106121
echo "DOVECOTE_TOKEN=${{ inputs.token }}" >> "$GITHUB_ENV"
107122
fi
108123
if [ -n "${{ inputs.server-url }}" ]; then
109124
echo "DOVECOTE_SERVER_URL=${{ inputs.server-url }}" >> "$GITHUB_ENV"
110125
fi
111126
127+
- name: OIDC token exchange
128+
if: ${{ inputs.token == '' && inputs.oidc-audience != '' }}
129+
shell: bash
130+
env:
131+
ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
132+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }}
133+
DOVECOTE_SERVER_URL: ${{ inputs.server-url }}
134+
OIDC_AUDIENCE: ${{ inputs.oidc-audience }}
135+
MODE: oidc
136+
run: |
137+
bash "${{ github.action_path }}/../../scripts/oidc-exchange.sh"
138+
112139
- name: Smoke probe
113140
shell: bash
114141
run: |

scripts/oidc-exchange.sh

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
# oidc-exchange.sh — Exchange a GitHub Actions OIDC token for a dovecote token.
3+
#
4+
# Usage (called by .github/actions/setup-dovecote/action.yml):
5+
#
6+
# OIDC mode (default when MODE != legacy and INPUT_TOKEN is empty):
7+
# Required env:
8+
# ACTIONS_ID_TOKEN_REQUEST_URL — GitHub-provided OIDC token endpoint
9+
# ACTIONS_ID_TOKEN_REQUEST_TOKEN — Bearer token for that endpoint
10+
# DOVECOTE_SERVER_URL — base URL of the dovecote Worker
11+
# OIDC_AUDIENCE — audience claim expected by the Worker
12+
# Optional env:
13+
# GITHUB_ENV — path to file to append DOVECOTE_TOKEN= to
14+
#
15+
# Legacy mode (activated when MODE=legacy OR INPUT_TOKEN is non-empty):
16+
# Required env:
17+
# INPUT_TOKEN — the pre-issued dovecote token (dvct_...)
18+
# No HTTP requests are made.
19+
#
20+
# Constraint-4 note (doc): run this step ONLY when the token is about to be
21+
# used, not at the start of the job, to minimise the window a token is live.
22+
#
23+
set -euo pipefail
24+
25+
# ---------- mode detection --------------------------------------------------
26+
MODE="${MODE:-}"
27+
INPUT_TOKEN="${INPUT_TOKEN:-}"
28+
29+
if [[ "$MODE" == "legacy" || -n "$INPUT_TOKEN" ]]; then
30+
# ---- legacy path: write INPUT_TOKEN directly, zero HTTP ----
31+
echo "::add-mask::${INPUT_TOKEN}"
32+
echo "DOVECOTE_TOKEN=${INPUT_TOKEN}" >> "${GITHUB_ENV}"
33+
exit 0
34+
fi
35+
36+
# ---- OIDC path (default) ----
37+
38+
# Validate required variables before any network I/O
39+
if [[ -z "${DOVECOTE_SERVER_URL:-}" ]]; then
40+
echo "oidc-exchange: DOVECOTE_SERVER_URL is required in OIDC mode (set server-url input or DOVECOTE_SERVER_URL env)" >&2
41+
exit 1
42+
fi
43+
44+
if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]]; then
45+
echo "oidc-exchange: ACTIONS_ID_TOKEN_REQUEST_URL is not set — ensure the job has id-token: write permission" >&2
46+
exit 1
47+
fi
48+
49+
if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then
50+
echo "oidc-exchange: ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set" >&2
51+
exit 1
52+
fi
53+
54+
OIDC_AUDIENCE="${OIDC_AUDIENCE:-}"
55+
if [[ -z "$OIDC_AUDIENCE" ]]; then
56+
echo "oidc-exchange: OIDC_AUDIENCE is not set" >&2
57+
exit 1
58+
fi
59+
60+
# ---------- 1. Fetch GitHub OIDC id_token -----------------------------------
61+
OIDC_TMPFILE="$(mktemp)"
62+
trap 'rm -f "$OIDC_TMPFILE"' EXIT
63+
64+
# Build URL: append audience param (existing query string handled with &)
65+
if [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *"?"* ]]; then
66+
TOKEN_URL="${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${OIDC_AUDIENCE}"
67+
else
68+
TOKEN_URL="${ACTIONS_ID_TOKEN_REQUEST_URL}?audience=${OIDC_AUDIENCE}"
69+
fi
70+
71+
OIDC_HTTP_CODE="$(
72+
curl -sSL \
73+
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
74+
-H "Accept: application/json" \
75+
-w "%{http_code}" \
76+
-o "$OIDC_TMPFILE" \
77+
"$TOKEN_URL"
78+
)"
79+
80+
if [[ "$OIDC_HTTP_CODE" != 2* ]]; then
81+
echo "oidc-exchange: GitHub OIDC token fetch failed (HTTP ${OIDC_HTTP_CODE})" >&2
82+
exit 1
83+
fi
84+
85+
# Parse {"value":"<jwt>"} without jq — extract value of "value" key
86+
ID_TOKEN="$(grep -o '"value":"[^"]*"' "$OIDC_TMPFILE" | grep -o '"[^"]*"$' | tr -d '"')"
87+
if [[ -z "$ID_TOKEN" ]]; then
88+
echo "oidc-exchange: could not parse id_token from OIDC response" >&2
89+
exit 1
90+
fi
91+
92+
# ---------- 2. Exchange id_token for dovecote token -------------------------
93+
DVCT_TMPFILE="$(mktemp)"
94+
trap 'rm -f "$OIDC_TMPFILE" "$DVCT_TMPFILE"' EXIT
95+
96+
DVCT_HTTP_CODE="$(
97+
curl -sSL \
98+
-X POST \
99+
-H "Content-Type: application/json" \
100+
-H "Accept: application/json" \
101+
-d "{\"id_token\":\"${ID_TOKEN}\"}" \
102+
-w "%{http_code}" \
103+
-o "$DVCT_TMPFILE" \
104+
"${DOVECOTE_SERVER_URL}/v1/auth/github-oidc"
105+
)"
106+
107+
if [[ "$DVCT_HTTP_CODE" != 2* ]]; then
108+
echo "oidc-exchange: dovecote token exchange failed (HTTP ${DVCT_HTTP_CODE})" >&2
109+
exit 1
110+
fi
111+
112+
# Parse {"token":"dvct_...",...} without jq
113+
DVCT_TOKEN="$(grep -o '"token":"[^"]*"' "$DVCT_TMPFILE" | grep -o '"[^"]*"$' | tr -d '"')"
114+
if [[ -z "$DVCT_TOKEN" ]]; then
115+
echo "oidc-exchange: could not parse token from dovecote response" >&2
116+
exit 1
117+
fi
118+
119+
# ---------- 3. Mask BEFORE writing to GITHUB_ENV ----------------------------
120+
echo "::add-mask::${DVCT_TOKEN}"
121+
echo "DOVECOTE_TOKEN=${DVCT_TOKEN}" >> "${GITHUB_ENV}"

src/auth-github-oidc.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export function createAuthGithubOidcApp(services: GithubOidcServices) {
205205
);
206206
}
207207

208-
// --- 5. Owner check (AFTER verify, BEFORE issueToken) ---
208+
// --- 6. Owner check (AFTER verify, BEFORE issueToken) ---
209209
const repositoryOwner = outcome.claims.repository_owner;
210210
if (
211211
typeof repositoryOwner !== "string" ||
@@ -223,7 +223,7 @@ export function createAuthGithubOidcApp(services: GithubOidcServices) {
223223
return c.json({ error: "forbidden" }, 403);
224224
}
225225

226-
// --- 6. Resolve userId (auto-provision on first login) ---
226+
// --- 7. Resolve userId (auto-provision on first login) ---
227227
let user: AuthenticatedUser | null;
228228
try {
229229
user = await resolve(
@@ -275,7 +275,6 @@ export function createAuthGithubOidcApp(services: GithubOidcServices) {
275275
userId: user.userId,
276276
scopes,
277277
ttlSeconds,
278-
rateLimitNamespace: "github-oidc",
279278
authMethod: "oidc",
280279
auditEvent: "auth.exchange.oidc",
281280
auditExtras: { issuer: outcome.issuer },

src/auth/issue-token-flow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface IssueTokenFlowOpts {
3737
scopes: string[];
3838
label?: string;
3939
ttlSeconds?: number;
40-
rateLimitNamespace: string;
40+
rateLimitNamespace?: string;
4141
rateLimitLimit?: number;
4242
authMethod: string;
4343
/**
@@ -138,7 +138,7 @@ export async function issueTokenFlow(
138138

139139
// 1. Rate limit (optional — omit from services to skip)
140140
if (services.checkRateLimit) {
141-
const rl = await services.checkRateLimit(env.OAUTH_KV, ip, rateLimitNamespace, rateLimitLimit);
141+
const rl = await services.checkRateLimit(env.OAUTH_KV, ip, rateLimitNamespace ?? "", rateLimitLimit);
142142
if (!rl.allowed) {
143143
writeAudit(env, ctx, {
144144
event: auditEvent,

0 commit comments

Comments
 (0)