Skip to content

Commit 601637d

Browse files
committed
(feat) implement GitHub OIDC token handling and workflow registration
1 parent 1bfcd82 commit 601637d

3 files changed

Lines changed: 132 additions & 13 deletions

File tree

cli/src/index.ts

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
11
import { Command } from "commander";
2-
import { createWalletClient, createPublicClient, http, parseAbiItem } from "viem";
2+
import { createWalletClient, createPublicClient, http, parseAbiItem, toHex } from "viem";
33
import type { Abi } from "viem";
44
import { privateKeyToAccount } from "viem/accounts";
55
import { sepolia, foundry } from "viem/chains";
66
import { importAbi } from "@/importAbi.js";
77
import packageJson from "../package.json" with { type: "json" };
88
import { exit } from "node:process";
9-
import { parseJwt } from "./oidc.js";
9+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
10+
import { dirname } from "node:path";
11+
import { b64urlToHex, jwtKid, parseJwt } from "./oidc.js";
1012

1113
const COMMAND_NAME = "fcf";
1214
const COMMAND_DESCRIPTION = "FCF CLI tool."
1315
const VERSION = packageJson?.version || "v0.0.1";
16+
const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
17+
const REGISTER_WORKFLOW_PATH = ".github/workflows/fcf-register.yml";
18+
const REGISTER_WORKFLOW = `name: Register Repository
19+
20+
on:
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
id-token: write
26+
27+
jobs:
28+
register:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v6
32+
33+
- uses: actions/setup-node@v6
34+
with:
35+
node-version: 24
36+
37+
- name: Register repository
38+
env:
39+
PRIVATE_KEY: \${{ secrets.FCF_PRIVATE_KEY }}
40+
RPC_URL: \${{ vars.FCF_RPC_URL }}
41+
FCF_CONTRACT: \${{ vars.FCF_CONTRACT }}
42+
run: |
43+
npx --yes @freecodexyz/cli register \\
44+
--contract "$FCF_CONTRACT"
45+
`;
1446

1547
function die(err: any): never {
1648
let error = "unknown error";
@@ -51,17 +83,54 @@ function clients() {
5183

5284
}
5385

86+
async function requestGithubOidcToken(audience: string): Promise<string> {
87+
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
88+
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
89+
if (!requestUrl || !requestToken) die(new Error("GitHub OIDC env vars not found"));
90+
91+
const separator = requestUrl.includes("?") ? "&" : "?";
92+
// include aud in payload -> aud == eth address of signer
93+
const res = await fetch(`${requestUrl}${separator}audience=${encodeURIComponent(audience)}`, {
94+
headers: { authorization: `bearer ${requestToken}` },
95+
});
96+
if (!res.ok) die(new Error(`failed to fetch GitHub OIDC token: ${res.status}`));
97+
98+
const body = await res.json() as { value?: string };
99+
if (!body.value) die(new Error("GitHub OIDC response did not contain token"));
100+
return body.value;
101+
}
102+
103+
async function fetchJson(url: string): Promise<any> {
104+
const res = await fetch(url);
105+
if (!res.ok) die(new Error(`request failed: ${url} (${res.status})`));
106+
return res.json();
107+
}
108+
109+
program
110+
.command("init")
111+
.option("--force", "overwrite existing workflow")
112+
.action((opts) => {
113+
if (existsSync(REGISTER_WORKFLOW_PATH) && !opts.force) {
114+
die(new Error(`${REGISTER_WORKFLOW_PATH} already exists`));
115+
}
116+
117+
mkdirSync(dirname(REGISTER_WORKFLOW_PATH), { recursive: true });
118+
writeFileSync(REGISTER_WORKFLOW_PATH, REGISTER_WORKFLOW);
119+
console.log(`created: ${REGISTER_WORKFLOW_PATH}`);
120+
});
121+
54122
program
55123
.command("register")
56-
.requiredOption("--oidcToken <token>", "GitHub's OIDC repository token")
124+
.option("--oidc-token <token>", "GitHub's OIDC repository token")
57125
// needs to be removed in prod or be a default
58126
.requiredOption("--contract <addr>", "deployed RIK address")
59127
.action(async (opts) => {
60128
const { wallet, publicC, account } = clients();
129+
const oidcToken = opts.oidcToken ?? await requestGithubOidcToken(account.address.toLowerCase());
61130

62131
const jwt = (() => {
63132
try {
64-
return parseJwt(opts.oidcToken);
133+
return parseJwt(oidcToken);
65134
} catch(err) {
66135
die(err);
67136
}
@@ -73,18 +142,52 @@ program
73142
}
74143
const repoId = BigInt(jwt.payload.repository_id);
75144
const ownerId = BigInt(jwt.payload.repository_owner_id);
145+
if (!jwt.header.kid) die(new Error("jwt kid missing"));
76146

77147
const hash = await wallet.writeContract({
78148
address: opts.contract as `0x${string}`,
79149
abi,
80150
functionName: "register",
81-
args: [repoId, ownerId],
151+
args: [
152+
jwtKid(jwt.header.kid),
153+
toHex(jwt.headerB64),
154+
toHex(jwt.payloadB64),
155+
b64urlToHex(jwt.signatureB64),
156+
repoId,
157+
ownerId,
158+
],
82159
account,
83160
});
84161
const r = await publicC.waitForTransactionReceipt({ hash });
85162
console.log(`registered: ${hash} status=${r.status}`);
86163
});
87164

165+
program
166+
.command("keys")
167+
.command("sync")
168+
// needs to be removed in prod or be a default
169+
.requiredOption("--contract <addr>", "deployed RIK address")
170+
.action(async (opts) => {
171+
const { wallet, publicC, account } = clients();
172+
const config = await fetchJson(`${GITHUB_ISSUER}/.well-known/openid-configuration`);
173+
const jwks = await fetchJson(config.jwks_uri);
174+
175+
for (const key of jwks.keys ?? []) {
176+
if (key.kty !== "RSA" || !key.kid || !key.n || !key.e) continue;
177+
178+
const kid = jwtKid(key.kid);
179+
const hash = await wallet.writeContract({
180+
address: opts.contract as `0x${string}`,
181+
abi,
182+
functionName: "addKey",
183+
args: [kid, b64urlToHex(key.n), b64urlToHex(key.e)],
184+
account,
185+
});
186+
const r = await publicC.waitForTransactionReceipt({ hash });
187+
console.log(`key synced: ${key.kid} kid=${kid} status=${r.status}`);
188+
}
189+
});
190+
88191
program
89192
.command("list")
90193
// needs to be removed in prod or be a default
@@ -107,4 +210,4 @@ program
107210
}
108211
});
109212

110-
program.parseAsync(process.argv);
213+
program.parseAsync(process.argv);

cli/src/oidc.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1+
import { keccak256 } from "viem";
2+
13
function b64urlDecode(s: string): Buffer {
24
s = s.replace(/-/g, "+").replace(/_/g, "/");
35
while (s.length % 4) s += "=";
46
return Buffer.from(s, "base64");
57
}
68

9+
export function b64urlToHex(s: string): `0x${string}` {
10+
return `0x${b64urlDecode(s).toString("hex")}`;
11+
}
12+
13+
export function jwtKid(kid: string): `0x${string}` {
14+
return keccak256(Buffer.from(kid, "utf8"));
15+
}
16+
717
export type JwtParts = {
818
headerB64: string; payloadB64: string; signatureB64: string;
19+
header: any;
920
payload: any;
1021
}
1122

1223
export function parseJwt(jwt: string): JwtParts {
1324
const [headerB64, payloadB64, signatureB64] = jwt.split(".");
1425
if (!payloadB64 || !headerB64 || !signatureB64) throw Error("Failed to decode jwt");
26+
const header = JSON.parse(b64urlDecode(headerB64).toString("utf-8"));
1527
const payload = JSON.parse(b64urlDecode(payloadB64).toString("utf-8"));
16-
return { headerB64, payloadB64, signatureB64, payload };
17-
}
28+
return { headerB64, payloadB64, signatureB64, header, payload };
29+
}

cli/test/oidc.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { test, expect } from "vitest";
2-
import { parseJwt } from "@/oidc.js";
2+
import { b64urlToHex, jwtKid, parseJwt } from "@/oidc.js";
33

44
test("parses GitHub OIDC payload", () => {
55
// real (but expired) token
6-
const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjB0WVZjTSJ9" +
6+
const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZC0wMDEifQ" +
77
".eyJyZXBvc2l0b3J5X2lkIjoiMTIzNDUiLCJyZXBvc2l0b3J5X293bmV" +
8-
"yX2lkIjoiOTk5IiwiYXVkIjoiMHhmMzlmIn0.AAAA";
8+
"yX2lkIjoiOTk5IiwiYXVkIjoiMHhmMzlmIn0.AAEC";
99

10-
const p = parseJwt(jwt).payload;
10+
const jwt_ = parseJwt(jwt);
11+
const p = jwt_.payload;
12+
expect(jwt_.header.kid).toBe("kid-001");
1113
expect(p.repository_id).toBe("12345");
1214
expect(p.repository_owner_id).toBe("999");
13-
});
15+
expect(b64urlToHex(jwt_.signatureB64)).toBe("0x000102");
16+
expect(jwtKid(jwt_.header.kid)).toMatch(/^0x[0-9a-f]{64}$/);
17+
});

0 commit comments

Comments
 (0)