uncial-cms-auth 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # uncial-cms-auth
2
+
3
+ A stateless Cloudflare Worker that turns a GitHub OAuth sign-in into a
4
+ **single-repo-scoped GitHub App installation token** for
5
+ [uncial-cms](../uncial-cms). No KV, no database, no sessions: all
6
+ cross-request state rides in an HMAC-signed `state` value.
7
+
8
+ The project runs a **canonical hosted instance** at
9
+ `https://uncial-cms-auth.dflood.workers.dev` — point your site config's
10
+ `authWorkerUrl` at it and no deployment is needed. Self-hosting is equally
11
+ first-class (see below); the `uncial-cms` runtime accepts any `authWorkerUrl`.
12
+
13
+ ## What it guarantees
14
+
15
+ - The user's OAuth token is used server-side only and is never sent to the
16
+ browser.
17
+ - A token is released only if the authenticated user has **push** permission
18
+ on the claimed repository **and** the initiating origin is listed in that
19
+ repository's committed allowlist (see below).
20
+ - The token the browser receives is a GitHub App installation access token
21
+ restricted to that one repository with contents read/write only (~1 hour
22
+ expiry).
23
+
24
+ ## Endpoints
25
+
26
+ The canonical instance serves these at
27
+ `https://uncial-cms-auth.dflood.workers.dev`; a self-hosted worker serves them
28
+ at its own `*.workers.dev` (or custom) domain.
29
+
30
+ - `GET /auth?repo=<owner/name>&origin=<origin>&challenge=<S256-challenge>` —
31
+ validates the parameters, issues a signed `state`, and redirects to GitHub's
32
+ authorize page (PKCE S256).
33
+ - `GET /callback?code&state` — static relay page that `postMessage`s
34
+ `{ code, state }` to `window.opener` at exactly the origin recovered from
35
+ the verified `state`, then closes.
36
+ - `POST /token` `{ code, state, verifier }` — verifies the state and PKCE
37
+ verifier, exchanges the code server-side, runs the permission and allowlist
38
+ checks, and responds `{ token, expiresAt, repo, user }`. The
39
+ `Access-Control-Allow-Origin` header echoes the state's origin only.
40
+
41
+ Refusals are distinct 4xx responses with a machine-readable
42
+ `{ "error": "<code>" }` body: `invalid_repo`, `invalid_origin`,
43
+ `invalid_challenge`, `invalid_request`, `invalid_state`, `stale_state`,
44
+ `invalid_verifier`, `code_exchange_failed`, `no_push_permission`,
45
+ `app_not_installed`, `missing_allowlist`, `origin_not_allowed`.
46
+
47
+ ## Registering a site (site owners)
48
+
49
+ There is no registration UI. Commit an allowlist file to your repository's
50
+ default branch:
51
+
52
+ ```json
53
+ // .uncial/cms.json
54
+ { "allowedOrigins": ["https://example.com", "http://localhost:5173"] }
55
+ ```
56
+
57
+ Anyone with push access can edit this file; protect it with branch protection
58
+ if that matters to you. Note that on shared-origin hosts (e.g.
59
+ `<user>.github.io` project pages) allowlisting the origin authorizes every
60
+ site served from it — a custom domain restores per-site granularity.
61
+
62
+ Then install the GitHub App on the repository (the `appSlug` in your site
63
+ config links editors to the install page).
64
+
65
+ ## Self-hosting
66
+
67
+ The canonical instance is hosted by the project, but self-hosting is
68
+ first-class — the `uncial-cms` runtime accepts any `authWorkerUrl`.
69
+
70
+ 1. **Create a GitHub App** (Settings → Developer settings → GitHub Apps):
71
+ - Repository permissions: **Contents: Read and write**. Nothing else.
72
+ - Webhooks: disabled.
73
+ - Callback URL: `https://<your-worker-domain>/callback`.
74
+ - "Request user authorization (OAuth) during installation" is not
75
+ required; editors authorize on first sign-in.
76
+ - Generate a **client secret** and a **private key**.
77
+ 2. **Convert the private key to PKCS#8** (GitHub downloads PKCS#1, which
78
+ WebCrypto cannot import):
79
+
80
+ ```sh
81
+ openssl pkcs8 -topk8 -nocrypt -in app.private-key.pem -out app.pkcs8.pem
82
+ ```
83
+
84
+ 3. **Set the five secrets** (names are the contract):
85
+
86
+ ```sh
87
+ wrangler secret put GITHUB_APP_ID
88
+ wrangler secret put GITHUB_APP_PRIVATE_KEY # paste the PKCS#8 PEM
89
+ wrangler secret put GITHUB_CLIENT_ID
90
+ wrangler secret put GITHUB_CLIENT_SECRET
91
+ wrangler secret put STATE_SIGNING_SECRET # any long random string
92
+ ```
93
+
94
+ 4. **Deploy:**
95
+
96
+ ```sh
97
+ wrangler deploy
98
+ ```
99
+
100
+ Point your site config's `authWorkerUrl` at the worker and set `appSlug` to
101
+ your GitHub App's slug.
102
+
103
+ ## Development
104
+
105
+ ```sh
106
+ pnpm run test # vitest, GitHub API fully mocked
107
+ pnpm run check # tsc --noEmit
108
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "uncial-cms-auth",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "description": "Stateless Cloudflare Worker that exchanges a GitHub OAuth dance for a single-repo-scoped GitHub App installation token, for uncial-cms.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/d-flood/uncial.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/d-flood/uncial/issues"
12
+ },
13
+ "homepage": "https://d-flood.github.io/uncial/",
14
+ "type": "module",
15
+ "files": [
16
+ "src",
17
+ "wrangler.jsonc",
18
+ "!src/**/*.spec.*"
19
+ ],
20
+ "scripts": {
21
+ "check": "tsc --noEmit",
22
+ "test": "vitest",
23
+ "deploy": "wrangler deploy"
24
+ },
25
+ "devDependencies": {
26
+ "@cloudflare/workers-types": "^4.20250620.0",
27
+ "typescript": "^5.9.3",
28
+ "vitest": "^4.0.18",
29
+ "wrangler": "^4.0.0"
30
+ },
31
+ "keywords": [
32
+ "cloudflare-worker",
33
+ "github-app",
34
+ "oauth",
35
+ "cms"
36
+ ]
37
+ }
@@ -0,0 +1,27 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ export function utf8(value: string): Uint8Array {
4
+ return encoder.encode(value);
5
+ }
6
+
7
+ export function base64UrlEncode(bytes: Uint8Array | ArrayBuffer): string {
8
+ const view = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
9
+ let binary = '';
10
+ for (const byte of view) binary += String.fromCharCode(byte);
11
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
12
+ }
13
+
14
+ export function base64UrlDecode(value: string): Uint8Array {
15
+ const padded = value.replaceAll('-', '+').replaceAll('_', '/');
16
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
17
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
18
+ }
19
+
20
+ export async function sha256Base64Url(value: string): Promise<string> {
21
+ return base64UrlEncode(await crypto.subtle.digest('SHA-256', utf8(value)));
22
+ }
23
+
24
+ export function pemToBytes(pem: string): Uint8Array {
25
+ const body = pem.replace(/-----(BEGIN|END)[A-Z ]+-----/g, '').replace(/\s+/g, '');
26
+ return Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
27
+ }
package/src/github.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { appJwt } from './jwt.js';
2
+
3
+ export const GITHUB_OAUTH_URL = 'https://github.com';
4
+ export const GITHUB_API_URL = 'https://api.github.com';
5
+
6
+ export interface Env {
7
+ GITHUB_APP_ID: string;
8
+ GITHUB_APP_PRIVATE_KEY: string;
9
+ GITHUB_CLIENT_ID: string;
10
+ GITHUB_CLIENT_SECRET: string;
11
+ STATE_SIGNING_SECRET: string;
12
+ }
13
+
14
+ /** Thrown by GitHub calls; the handler maps `code` to a 4xx/5xx `{error}` body. */
15
+ export class RefusalError extends Error {
16
+ constructor(
17
+ public readonly code: string,
18
+ public readonly status: number
19
+ ) {
20
+ super(code);
21
+ }
22
+ }
23
+
24
+ function apiHeaders(token: string, scheme: 'Bearer' = 'Bearer'): HeadersInit {
25
+ return {
26
+ Accept: 'application/vnd.github+json',
27
+ Authorization: `${scheme} ${token}`,
28
+ 'X-GitHub-Api-Version': '2022-11-28',
29
+ 'User-Agent': 'uncial-cms-auth'
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Exchanges the OAuth code (+ client secret + PKCE verifier) for the user's
35
+ * token. The returned token is used server-side only and must never appear in
36
+ * any response (ticket invariant 1).
37
+ */
38
+ export async function exchangeCode(
39
+ env: Env,
40
+ code: string,
41
+ verifier: string,
42
+ redirectUri: string
43
+ ): Promise<string> {
44
+ const response = await fetch(`${GITHUB_OAUTH_URL}/login/oauth/access_token`, {
45
+ method: 'POST',
46
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({
48
+ client_id: env.GITHUB_CLIENT_ID,
49
+ client_secret: env.GITHUB_CLIENT_SECRET,
50
+ code,
51
+ code_verifier: verifier,
52
+ redirect_uri: redirectUri
53
+ })
54
+ });
55
+ const body = (await response.json().catch(() => ({}))) as {
56
+ access_token?: string;
57
+ error?: string;
58
+ };
59
+ if (!response.ok || !body.access_token) throw new RefusalError('code_exchange_failed', 400);
60
+ return body.access_token;
61
+ }
62
+
63
+ export interface GitHubUser {
64
+ login: string;
65
+ id: number;
66
+ name: string | null;
67
+ }
68
+
69
+ export async function fetchUser(userToken: string): Promise<GitHubUser> {
70
+ const response = await fetch(`${GITHUB_API_URL}/user`, { headers: apiHeaders(userToken) });
71
+ if (!response.ok) throw new RefusalError('code_exchange_failed', 400);
72
+ return (await response.json()) as GitHubUser;
73
+ }
74
+
75
+ /** Ticket invariant 2a: the authenticated user must have push on the claimed repo. */
76
+ export async function assertPushPermission(
77
+ userToken: string,
78
+ repo: string,
79
+ login: string
80
+ ): Promise<void> {
81
+ const response = await fetch(
82
+ `${GITHUB_API_URL}/repos/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
83
+ { headers: apiHeaders(userToken) }
84
+ );
85
+ if (!response.ok) throw new RefusalError('no_push_permission', 403);
86
+ const body = (await response.json()) as {
87
+ permission?: string;
88
+ user?: { permissions?: { push?: boolean } };
89
+ };
90
+ const canPush =
91
+ body.user?.permissions?.push === true ||
92
+ body.permission === 'admin' ||
93
+ body.permission === 'write';
94
+ if (!canPush) throw new RefusalError('no_push_permission', 403);
95
+ }
96
+
97
+ /**
98
+ * Ticket invariant 3: mints an installation access token restricted to the one
99
+ * claimed repository with contents read/write only.
100
+ */
101
+ export async function mintScopedToken(
102
+ env: Env,
103
+ repo: string
104
+ ): Promise<{ token: string; expiresAt: number }> {
105
+ const jwt = await appJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY);
106
+
107
+ const installation = await fetch(`${GITHUB_API_URL}/repos/${repo}/installation`, {
108
+ headers: apiHeaders(jwt)
109
+ });
110
+ if (!installation.ok) throw new RefusalError('app_not_installed', 403);
111
+ const { id } = (await installation.json()) as { id: number };
112
+
113
+ const [, name] = repo.split('/');
114
+ const minted = await fetch(`${GITHUB_API_URL}/app/installations/${id}/access_tokens`, {
115
+ method: 'POST',
116
+ headers: { ...apiHeaders(jwt), 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ repositories: [name], permissions: { contents: 'write' } })
118
+ });
119
+ if (!minted.ok) throw new RefusalError('token_mint_failed', 502);
120
+ const body = (await minted.json()) as { token: string; expires_at: string };
121
+ return { token: body.token, expiresAt: Date.parse(body.expires_at) };
122
+ }
123
+
124
+ /**
125
+ * Ticket invariant 2b: `.uncial/cms.json` on the repo's default branch must
126
+ * list the initiating origin. Read server-to-server with the worker's own
127
+ * installation token — which is only released to the browser if this passes.
128
+ */
129
+ export async function assertOriginAllowed(
130
+ installationToken: string,
131
+ repo: string,
132
+ origin: string
133
+ ): Promise<void> {
134
+ const response = await fetch(`${GITHUB_API_URL}/repos/${repo}/contents/.uncial/cms.json`, {
135
+ headers: apiHeaders(installationToken)
136
+ });
137
+ if (!response.ok) throw new RefusalError('missing_allowlist', 403);
138
+
139
+ let allowedOrigins: unknown;
140
+ try {
141
+ const file = (await response.json()) as { content?: string };
142
+ const decoded = atob((file.content ?? '').replace(/\s+/g, ''));
143
+ allowedOrigins = (JSON.parse(decoded) as { allowedOrigins?: unknown }).allowedOrigins;
144
+ } catch {
145
+ throw new RefusalError('missing_allowlist', 403);
146
+ }
147
+ if (!Array.isArray(allowedOrigins) || !allowedOrigins.includes(origin)) {
148
+ throw new RefusalError('origin_not_allowed', 403);
149
+ }
150
+ }
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { sha256Base64Url } from './encoding.js';
2
+ import {
3
+ assertOriginAllowed,
4
+ assertPushPermission,
5
+ exchangeCode,
6
+ fetchUser,
7
+ GITHUB_OAUTH_URL,
8
+ mintScopedToken,
9
+ RefusalError,
10
+ type Env
11
+ } from './github.js';
12
+ import { signState, verifyState } from './state.js';
13
+
14
+ const STATE_MAX_AGE_MS = 10 * 60 * 1000;
15
+ const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
16
+ // 43 chars = base64url of 32 bytes; RFC 7636 allows 43–128 for the verifier,
17
+ // and an S256 challenge is always exactly 43.
18
+ const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
19
+
20
+ function json(status: number, body: unknown, headers: HeadersInit = {}): Response {
21
+ return new Response(JSON.stringify(body), {
22
+ status,
23
+ headers: { 'Content-Type': 'application/json', ...headers }
24
+ });
25
+ }
26
+
27
+ function refusal(status: number, error: string, corsOrigin?: string): Response {
28
+ return json(
29
+ status,
30
+ { error },
31
+ corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin, Vary: 'Origin' } : {}
32
+ );
33
+ }
34
+
35
+ function isWebOrigin(value: string): boolean {
36
+ try {
37
+ return new URL(value).origin === value;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function handleAuth(request: Request, env: Env): Promise<Response> {
44
+ const url = new URL(request.url);
45
+ const repo = url.searchParams.get('repo') ?? '';
46
+ const origin = url.searchParams.get('origin') ?? '';
47
+ const challenge = url.searchParams.get('challenge') ?? '';
48
+
49
+ if (!REPO_PATTERN.test(repo)) return Promise.resolve(refusal(400, 'invalid_repo'));
50
+ if (!isWebOrigin(origin)) return Promise.resolve(refusal(400, 'invalid_origin'));
51
+ if (!CHALLENGE_PATTERN.test(challenge)) return Promise.resolve(refusal(400, 'invalid_challenge'));
52
+
53
+ return signState({ repo, origin, challenge, iat: Date.now() }, env.STATE_SIGNING_SECRET).then(
54
+ (state) => {
55
+ const authorize = new URL(`${GITHUB_OAUTH_URL}/login/oauth/authorize`);
56
+ authorize.searchParams.set('client_id', env.GITHUB_CLIENT_ID);
57
+ authorize.searchParams.set('redirect_uri', new URL('/callback', url).toString());
58
+ authorize.searchParams.set('state', state);
59
+ authorize.searchParams.set('code_challenge', challenge);
60
+ authorize.searchParams.set('code_challenge_method', 'S256');
61
+ return Response.redirect(authorize.toString(), 302);
62
+ }
63
+ );
64
+ }
65
+
66
+ function errorPage(status: number, message: string): Response {
67
+ return new Response(
68
+ `<!doctype html><meta charset="utf-8"><title>uncial-cms sign-in</title><p>${message}</p>`,
69
+ { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Static relay: hands {code, state} back to the opener at exactly the origin
75
+ * recovered from the verified state — the PKCE verifier never left the
76
+ * browser, so only that opener can finish the exchange at /token.
77
+ */
78
+ async function handleCallback(request: Request, env: Env): Promise<Response> {
79
+ const url = new URL(request.url);
80
+ const code = url.searchParams.get('code');
81
+ const state = url.searchParams.get('state');
82
+ if (!code || !state) {
83
+ return errorPage(400, 'GitHub did not complete the sign-in. You can close this window.');
84
+ }
85
+
86
+ const payload = await verifyState(state, env.STATE_SIGNING_SECRET);
87
+ if (!payload) return errorPage(400, 'Invalid sign-in state. You can close this window.');
88
+
89
+ // JSON.stringify + `<` escaping keeps the embedded values inert in HTML.
90
+ const message = JSON.stringify({ source: 'uncial-cms-auth', code, state }).replaceAll(
91
+ '<',
92
+ '\\u003c'
93
+ );
94
+ const target = JSON.stringify(payload.origin).replaceAll('<', '\\u003c');
95
+ return new Response(
96
+ `<!doctype html><meta charset="utf-8"><title>uncial-cms sign-in</title>` +
97
+ `<p>Completing sign-in… you can close this window.</p>` +
98
+ `<script>window.opener?.postMessage(${message}, ${target});window.close();</script>`,
99
+ { headers: { 'Content-Type': 'text/html; charset=utf-8' } }
100
+ );
101
+ }
102
+
103
+ async function handleToken(request: Request, env: Env): Promise<Response> {
104
+ const requestOrigin = request.headers.get('Origin') ?? '';
105
+
106
+ let body: { code?: unknown; state?: unknown; verifier?: unknown };
107
+ try {
108
+ body = (await request.json()) as typeof body;
109
+ } catch {
110
+ return refusal(400, 'invalid_request', requestOrigin);
111
+ }
112
+ const { code, state, verifier } = body;
113
+ if (typeof code !== 'string' || typeof state !== 'string' || typeof verifier !== 'string') {
114
+ return refusal(400, 'invalid_request', requestOrigin);
115
+ }
116
+
117
+ const payload = await verifyState(state, env.STATE_SIGNING_SECRET);
118
+ if (!payload) return refusal(400, 'invalid_state', requestOrigin);
119
+ // CORS: every response past this point is addressed to the state's origin
120
+ // only — never `*`, never the caller's own Origin header.
121
+ const cors = payload.origin;
122
+
123
+ if (Date.now() - payload.iat > STATE_MAX_AGE_MS) return refusal(400, 'stale_state', cors);
124
+ if ((await sha256Base64Url(verifier)) !== payload.challenge) {
125
+ return refusal(400, 'invalid_verifier', cors);
126
+ }
127
+
128
+ try {
129
+ const redirectUri = new URL('/callback', request.url).toString();
130
+ const userToken = await exchangeCode(env, code, verifier, redirectUri);
131
+ const user = await fetchUser(userToken);
132
+ await assertPushPermission(userToken, payload.repo, user.login);
133
+
134
+ const scoped = await mintScopedToken(env, payload.repo);
135
+ await assertOriginAllowed(scoped.token, payload.repo, payload.origin);
136
+
137
+ return json(
138
+ 200,
139
+ {
140
+ token: scoped.token,
141
+ expiresAt: scoped.expiresAt,
142
+ repo: payload.repo,
143
+ user: {
144
+ login: user.login,
145
+ name: user.name ?? user.login,
146
+ email: `${user.id}+${user.login}@users.noreply.github.com`
147
+ }
148
+ },
149
+ { 'Access-Control-Allow-Origin': cors, Vary: 'Origin' }
150
+ );
151
+ } catch (error) {
152
+ if (error instanceof RefusalError) return refusal(error.status, error.code, cors);
153
+ return refusal(502, 'github_unreachable', cors);
154
+ }
155
+ }
156
+
157
+ function handleTokenPreflight(request: Request): Response {
158
+ // The preflight only gates whether the POST may be *sent*; the response the
159
+ // page can actually read is still bound to the verified state origin.
160
+ return new Response(null, {
161
+ status: 204,
162
+ headers: {
163
+ 'Access-Control-Allow-Origin': request.headers.get('Origin') ?? '*',
164
+ 'Access-Control-Allow-Methods': 'POST',
165
+ 'Access-Control-Allow-Headers': 'Content-Type',
166
+ 'Access-Control-Max-Age': '86400',
167
+ Vary: 'Origin'
168
+ }
169
+ });
170
+ }
171
+
172
+ export default {
173
+ async fetch(request: Request, env: Env): Promise<Response> {
174
+ const { pathname } = new URL(request.url);
175
+ if (pathname === '/auth' && request.method === 'GET') return handleAuth(request, env);
176
+ if (pathname === '/callback' && request.method === 'GET') return handleCallback(request, env);
177
+ if (pathname === '/token' && request.method === 'POST') return handleToken(request, env);
178
+ if (pathname === '/token' && request.method === 'OPTIONS') {
179
+ return handleTokenPreflight(request);
180
+ }
181
+ return json(404, { error: 'not_found' });
182
+ }
183
+ };
package/src/jwt.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { base64UrlEncode, pemToBytes, utf8 } from './encoding.js';
2
+
3
+ /**
4
+ * GitHub App JWT (RS256), used for app-to-server calls: resolving the repo's
5
+ * installation and minting the scoped installation token. The private key must
6
+ * be PKCS#8 PEM (WebCrypto cannot import GitHub's default PKCS#1 download;
7
+ * convert once with `openssl pkcs8 -topk8 -nocrypt` — see the README).
8
+ */
9
+ export async function appJwt(appId: string, privateKeyPem: string): Promise<string> {
10
+ const key = await crypto.subtle.importKey(
11
+ 'pkcs8',
12
+ pemToBytes(privateKeyPem),
13
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
14
+ false,
15
+ ['sign']
16
+ );
17
+
18
+ const now = Math.floor(Date.now() / 1000);
19
+ const header = base64UrlEncode(utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
20
+ // iat backdated 60s for clock drift; 9-minute expiry (GitHub caps at 10).
21
+ const payload = base64UrlEncode(
22
+ utf8(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }))
23
+ );
24
+ const signature = await crypto.subtle.sign(
25
+ 'RSASSA-PKCS1-v1_5',
26
+ key,
27
+ utf8(`${header}.${payload}`)
28
+ );
29
+ return `${header}.${payload}.${base64UrlEncode(signature)}`;
30
+ }
package/src/state.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { base64UrlDecode, base64UrlEncode, utf8 } from './encoding.js';
2
+
3
+ /**
4
+ * Cross-request state for the stateless worker (spec D4 / ticket invariant 4):
5
+ * everything the /token step must trust — repo, origin, PKCE challenge, issue
6
+ * time — rides in an HMAC-SHA256-signed value the worker itself minted.
7
+ */
8
+ export interface StatePayload {
9
+ repo: string; // 'owner/name'
10
+ origin: string; // initiating site origin
11
+ challenge: string; // PKCE S256 challenge (base64url)
12
+ iat: number; // epoch ms
13
+ }
14
+
15
+ async function hmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {
16
+ return crypto.subtle.importKey('raw', utf8(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
17
+ usage
18
+ ]);
19
+ }
20
+
21
+ export async function signState(payload: StatePayload, secret: string): Promise<string> {
22
+ const body = base64UrlEncode(utf8(JSON.stringify(payload)));
23
+ const signature = await crypto.subtle.sign('HMAC', await hmacKey(secret, 'sign'), utf8(body));
24
+ return `${body}.${base64UrlEncode(signature)}`;
25
+ }
26
+
27
+ export async function verifyState(state: string, secret: string): Promise<StatePayload | null> {
28
+ const [body, signature, ...rest] = state.split('.');
29
+ if (!body || !signature || rest.length > 0) return null;
30
+
31
+ let valid: boolean;
32
+ let payload: unknown;
33
+ try {
34
+ valid = await crypto.subtle.verify(
35
+ 'HMAC',
36
+ await hmacKey(secret, 'verify'),
37
+ base64UrlDecode(signature),
38
+ utf8(body)
39
+ );
40
+ payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!valid || typeof payload !== 'object' || payload === null) return null;
45
+
46
+ const { repo, origin, challenge, iat } = payload as Partial<StatePayload>;
47
+ if (
48
+ typeof repo !== 'string' ||
49
+ typeof origin !== 'string' ||
50
+ typeof challenge !== 'string' ||
51
+ typeof iat !== 'number'
52
+ ) {
53
+ return null;
54
+ }
55
+ return { repo, origin, challenge, iat };
56
+ }
package/wrangler.jsonc ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "uncial-cms-auth",
4
+ "main": "src/index.ts",
5
+ "compatibility_date": "2026-06-01"
6
+ // Secrets (set with `wrangler secret put <NAME>`):
7
+ // GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_CLIENT_ID,
8
+ // GITHUB_CLIENT_SECRET, STATE_SIGNING_SECRET
9
+ }