1// @effect-diagnostics globalDate:off globalFetch:off - vendored upstream protocol, not an Effect service.
2import {
3 createRemoteJWKSet,
4 customFetch,
5 decodeProtectedHeader,
6 errors,
7 jwtVerify,
8 type JWTVerifyGetKey,
9 type JWTPayload,
10} from 'jose';
11import { HttpError, unavailable } from './errors.ts';
12import { Deadline, readBody } from './streams.ts';
13import type { Scope } from './database.ts';
14
15export const ISSUER = 'https://token.actions.githubusercontent.com';
16export const JWKS_URL = `${ISSUER}/.well-known/jwks`;
17
18export function githubKeys(): JWTVerifyGetKey {
19 // Only reusable public-key cache state lives across requests. Failed refreshes
20 // also have a cooldown; jose's own cooldown starts only after a successful fetch.
21 let lastAttempt = -Infinity;
22 return createRemoteJWKSet(new URL(JWKS_URL), {
23 timeoutDuration: 5000,
24 cooldownDuration: 30_000,
25 cacheMaxAge: 10 * 60_000,
26 [customFetch]: async (url, options) => {
27 if (Date.now() - lastAttempt < 30_000) unavailable();
28 lastAttempt = Date.now();
29 const deadline = new Deadline(5000, options.signal);
30 try {
31 const response = await deadline.run(fetch(url, options));
32 if (response.status !== 200) {
33 void response.body?.cancel();
34 unavailable();
35 }
36 const bytes = await readBody(response.body, 64 * 1024, deadline);
37 return new Response(bytes, { headers: { 'Content-Type': 'application/json' } });
38 } catch {
39 unavailable();
40 } finally {
41 deadline.dispose();
42 }
43 },
44 });
45}
46
47export interface WriteIdentity {
48 exp: number;
49 repository_id: string;
50 workflow_ref?: string;
51 run_id?: string;
52 run_attempt?: string;
53 sha?: string;
54 event_name?: string;
55 head_ref?: string;
56}
57
58export async function authorize(
59 request: Request,
60 scope: Scope,
61 keys: JWTVerifyGetKey,
62): Promise<WriteIdentity> {
63 const authorization = request.headers.get('Authorization');
64 if (
65 !authorization ||
66 authorization.length > 16 * 1024 ||
67 !/^Bearer [A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/i.test(authorization)
68 ) {
69 throw new HttpError(401, 'invalid_token');
70 }
71 const token = authorization.slice(7);
72 let payload: JWTPayload;
73 try {
74 const header = decodeProtectedHeader(token);
75 if (
76 header.alg !== 'RS256' ||
77 typeof header.kid !== 'string' ||
78 header.kid.length > 256 ||
79 header.jku ||
80 header.x5u ||
81 header.jwk
82 ) {
83 throw new HttpError(401, 'invalid_token');
84 }
85 ({ payload } = await jwtVerify(token, keys, {
86 algorithms: ['RS256'],
87 issuer: ISSUER,
88 requiredClaims: ['exp', 'nbf', 'iat'],
89 clockTolerance: 30,
90 maxTokenAge: '15 minutes',
91 }));
92 } catch (error) {
93 if (error instanceof HttpError) throw error;
94 if (
95 error instanceof errors.JWTExpired ||
96 error instanceof errors.JWTClaimValidationFailed ||
97 error instanceof errors.JWSSignatureVerificationFailed ||
98 error instanceof errors.JWSInvalid ||
99 error instanceof errors.JWTInvalid ||
100 error instanceof errors.JOSENotSupported ||
101 error instanceof errors.JWKSNoMatchingKey ||
102 error instanceof errors.JOSEAlgNotAllowed
103 ) {
104 throw new HttpError(401, 'invalid_token');
105 }
106 unavailable();
107 }
108 const now = Date.now() / 1000;
109 const { exp, nbf, iat } = payload;
110 if (
111 typeof exp !== 'number' ||
112 typeof nbf !== 'number' ||
113 typeof iat !== 'number' ||
114 !Number.isSafeInteger(exp) ||
115 !Number.isSafeInteger(nbf) ||
116 !Number.isSafeInteger(iat) ||
117 exp <= now ||
118 nbf > now + 30 ||
119 iat > now + 30 ||
120 iat < now - 930 ||
121 nbf > exp ||
122 iat >= exp ||
123 exp - iat > 900
124 ) {
125 throw new HttpError(401, 'invalid_token');
126 }
127 // A `push` must update the registered branch. A namespace that opts in also accepts
128 // `pull_request` runs whose PR targets that branch. For those, GitHub signs `ref`
129 // `refs/pull/<number>/merge`, `ref_type` `branch`, and the target branch's short name (`main`)
130 // in `base_ref`. The `closed` run of a merged PR has the base branch in `ref` and is denied.
131 // Fork PR runs carry the base repository's IDs too; only GitHub withholding `id-token: write`
132 // from them keeps them out.
133 const event =
134 payload.event_name === 'push'
135 ? payload.ref === scope.branch
136 : payload.event_name === 'pull_request' &&
137 scope.pull_requests === 1 &&
138 typeof payload.ref === 'string' &&
139 /^refs\/pull\/[1-9][0-9]*\/merge$/.test(payload.ref) &&
140 typeof payload.base_ref === 'string' &&
141 `refs/heads/${payload.base_ref}` === scope.branch;
142 if (
143 !scope.writes_enabled ||
144 payload.aud !== scope.endpoint ||
145 payload.repository_id !== scope.repository_id ||
146 payload.repository_owner_id !== scope.repository_owner_id ||
147 payload.repository_visibility !== 'public' ||
148 payload.ref_type !== 'branch' ||
149 !event
150 ) {
151 throw new HttpError(403, 'write_policy');
152 }
153 const identity: WriteIdentity = { exp, repository_id: scope.repository_id };
154 for (const name of [
155 'workflow_ref',
156 'run_id',
157 'run_attempt',
158 'sha',
159 'event_name',
160 'head_ref',
161 ] as const) {
162 const value = payload[name];
163 if (typeof value === 'string' && value && value.length <= 512) identity[name] = value;
164 }
165 return identity;
166}