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 +108 -0
- package/package.json +37 -0
- package/src/encoding.ts +27 -0
- package/src/github.ts +150 -0
- package/src/index.ts +183 -0
- package/src/jwt.ts +30 -0
- package/src/state.ts +56 -0
- package/wrangler.jsonc +9 -0
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
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -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
|
+
}
|