@alteran/astro 0.6.3 → 0.7.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 +11 -0
- package/index.js +8 -0
- package/migrations/0009_oauth_session_state.sql +31 -0
- package/migrations/meta/0009_snapshot.json +749 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/db/account.ts +134 -1
- package/src/db/schema.ts +31 -0
- package/src/lib/appview/proxy.ts +11 -8
- package/src/lib/auth.ts +34 -3
- package/src/lib/jwt.ts +4 -0
- package/src/lib/oauth/as-keys.ts +29 -0
- package/src/lib/oauth/clients.ts +453 -24
- package/src/lib/oauth/consent.ts +180 -0
- package/src/lib/oauth/dpop.ts +39 -5
- package/src/lib/oauth/resource.ts +93 -21
- package/src/lib/oauth/store.ts +64 -7
- package/src/lib/refresh-session.ts +16 -0
- package/src/lib/session-tokens.ts +33 -5
- package/src/lib/token-cleanup.ts +4 -2
- package/src/lib/util.ts +0 -1
- package/src/pages/.well-known/oauth-authorization-server.ts +16 -3
- package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
- package/src/pages/oauth/authorize.ts +31 -52
- package/src/pages/oauth/consent.ts +163 -66
- package/src/pages/oauth/jwks.ts +15 -0
- package/src/pages/oauth/par.ts +34 -56
- package/src/pages/oauth/revoke.ts +75 -0
- package/src/pages/oauth/token.ts +148 -89
- package/src/pages/xrpc/[...nsid].ts +7 -6
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
- package/types/env.d.ts +1 -0
package/src/lib/oauth/clients.ts
CHANGED
|
@@ -1,67 +1,496 @@
|
|
|
1
|
+
import type { Env } from '../../env';
|
|
2
|
+
import { jwkThumbprint } from './dpop';
|
|
3
|
+
import { DpopNonceError } from './dpop-errors';
|
|
4
|
+
import { cleanupExpiredOAuthReplaySecrets, createSecretOnce } from '../../db/account';
|
|
1
5
|
import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
const MAX_CLIENT_JSON_BYTES = 128 * 1024;
|
|
8
|
+
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
9
|
+
|
|
10
|
+
export type ClientAuthMethod = 'none' | 'private_key_jwt';
|
|
11
|
+
|
|
12
|
+
export type OAuthClientMetadata = {
|
|
13
|
+
client_id: string;
|
|
14
|
+
redirect_uris: string[];
|
|
15
|
+
token_endpoint_auth_method: ClientAuthMethod;
|
|
16
|
+
grant_types: string[];
|
|
17
|
+
response_types: string[];
|
|
18
|
+
scope: string;
|
|
19
|
+
dpop_bound_access_tokens: true;
|
|
20
|
+
jwks?: { keys: JsonWebKey[] };
|
|
21
|
+
jwks_uri?: string;
|
|
22
|
+
application_type?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type VerifiedClientAuth = {
|
|
26
|
+
method: ClientAuthMethod;
|
|
27
|
+
keyId: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function configuredClientHosts(env: Env): Set<string> {
|
|
31
|
+
return new Set(
|
|
32
|
+
String(env.PDS_OAUTH_CLIENT_HOSTS || '')
|
|
33
|
+
.split(',')
|
|
34
|
+
.map((host) => host.trim().toLowerCase())
|
|
35
|
+
.filter(Boolean),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertClientHostAllowed(env: Env, url: URL, label: string): void {
|
|
40
|
+
const allowed = configuredClientHosts(env);
|
|
41
|
+
if (allowed.size === 0) {
|
|
42
|
+
throw new Error(`${label} host is not allowlisted`);
|
|
43
|
+
}
|
|
44
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
45
|
+
if (!allowed.has(host)) {
|
|
46
|
+
throw new Error(`${label} host is not allowlisted`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isIpLiteral(hostname: string): boolean {
|
|
51
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
52
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return true;
|
|
53
|
+
return host.includes(':');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBlockedHost(hostname: string): boolean {
|
|
57
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
58
|
+
if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
|
|
59
|
+
if (host.endsWith('.internal') || host.endsWith('.home.arpa')) return true;
|
|
60
|
+
if (isIpLiteral(host)) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ipv4ToNumber(ip: string): number | null {
|
|
65
|
+
const parts = ip.split('.');
|
|
66
|
+
if (parts.length !== 4) return null;
|
|
67
|
+
let n = 0;
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
if (!/^\d+$/.test(part)) return null;
|
|
70
|
+
const v = Number(part);
|
|
71
|
+
if (v < 0 || v > 255) return null;
|
|
72
|
+
n = (n << 8) + v;
|
|
73
|
+
}
|
|
74
|
+
return n >>> 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ipv4InRange(ip: number, base: string, bits: number): boolean {
|
|
78
|
+
const baseNumber = ipv4ToNumber(base);
|
|
79
|
+
if (baseNumber === null) return false;
|
|
80
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
81
|
+
return (ip & mask) === (baseNumber & mask);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isBlockedIpv4Number(ip: number): boolean {
|
|
85
|
+
return [
|
|
86
|
+
['0.0.0.0', 8],
|
|
87
|
+
['10.0.0.0', 8],
|
|
88
|
+
['100.64.0.0', 10],
|
|
89
|
+
['127.0.0.0', 8],
|
|
90
|
+
['169.254.0.0', 16],
|
|
91
|
+
['172.16.0.0', 12],
|
|
92
|
+
['192.0.0.0', 24],
|
|
93
|
+
['192.0.2.0', 24],
|
|
94
|
+
['192.168.0.0', 16],
|
|
95
|
+
['198.18.0.0', 15],
|
|
96
|
+
['198.51.100.0', 24],
|
|
97
|
+
['203.0.113.0', 24],
|
|
98
|
+
['224.0.0.0', 4],
|
|
99
|
+
['240.0.0.0', 4],
|
|
100
|
+
].some(([base, bits]) => ipv4InRange(ip, base as string, bits as number));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isBlockedIpAddress(value: string): boolean {
|
|
104
|
+
const host = value.toLowerCase().replace(/^\[|\]$/g, '');
|
|
105
|
+
const ipv4 = ipv4ToNumber(host);
|
|
106
|
+
if (ipv4 !== null) {
|
|
107
|
+
return isBlockedIpv4Number(ipv4);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!host.includes(':')) return false;
|
|
111
|
+
const dottedIpv4Suffix = host.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1];
|
|
112
|
+
if (dottedIpv4Suffix) {
|
|
113
|
+
const embedded = ipv4ToNumber(dottedIpv4Suffix);
|
|
114
|
+
if (embedded !== null) return true;
|
|
115
|
+
}
|
|
116
|
+
if (host.startsWith('::ffff:') || host.startsWith('0:0:0:0:0:ffff:')) return true;
|
|
117
|
+
return (
|
|
118
|
+
host === '::' ||
|
|
119
|
+
host === '::1' ||
|
|
120
|
+
host.startsWith('fc') ||
|
|
121
|
+
host.startsWith('fd') ||
|
|
122
|
+
host.startsWith('fe8') ||
|
|
123
|
+
host.startsWith('fe9') ||
|
|
124
|
+
host.startsWith('fea') ||
|
|
125
|
+
host.startsWith('feb') ||
|
|
126
|
+
host.startsWith('2001:db8') ||
|
|
127
|
+
host.startsWith('::ffff:10.') ||
|
|
128
|
+
host.startsWith('::ffff:127.') ||
|
|
129
|
+
host.startsWith('::ffff:192.168.')
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isSafeFetchUrl(u: string): boolean {
|
|
4
134
|
try {
|
|
5
135
|
const url = new URL(u);
|
|
6
136
|
if (url.protocol !== 'https:') return false;
|
|
7
|
-
|
|
8
|
-
if (
|
|
9
|
-
if (
|
|
137
|
+
if (url.username || url.password || url.hash) return false;
|
|
138
|
+
if (url.port && url.port !== '443') return false;
|
|
139
|
+
if (isBlockedHost(url.hostname)) return false;
|
|
140
|
+
if (isBlockedIpAddress(url.hostname)) return false;
|
|
10
141
|
return true;
|
|
11
142
|
} catch {
|
|
12
143
|
return false;
|
|
13
144
|
}
|
|
14
145
|
}
|
|
15
146
|
|
|
16
|
-
export
|
|
147
|
+
export function isHttpsUrl(u: string): boolean {
|
|
148
|
+
return isSafeFetchUrl(u);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
152
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
153
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isAllowedRedirectUri(uri: string): boolean {
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(uri);
|
|
159
|
+
if (url.username || url.password || url.hash) return false;
|
|
160
|
+
if (url.protocol === 'https:') {
|
|
161
|
+
return !isBlockedHost(url.hostname);
|
|
162
|
+
}
|
|
163
|
+
if (url.protocol === 'http:') {
|
|
164
|
+
return isLoopbackHostname(url.hostname);
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function redirectUriMatches(registered: string, requested: string): boolean {
|
|
173
|
+
if (registered === requested) return true;
|
|
174
|
+
try {
|
|
175
|
+
const reg = new URL(registered);
|
|
176
|
+
const req = new URL(requested);
|
|
177
|
+
if (reg.protocol !== 'http:' || req.protocol !== 'http:') return false;
|
|
178
|
+
if (!isLoopbackHostname(reg.hostname) || !isLoopbackHostname(req.hostname)) return false;
|
|
179
|
+
if (reg.hostname.toLowerCase() !== req.hostname.toLowerCase()) return false;
|
|
180
|
+
if (reg.pathname !== req.pathname || reg.search !== req.search) return false;
|
|
181
|
+
return !reg.port && !!req.port;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function safeFetchJson(env: Env, url: string, label: string): Promise<any> {
|
|
188
|
+
if (!isSafeFetchUrl(url)) {
|
|
189
|
+
throw new Error(`${label} URL is not safe to fetch`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const parsed = new URL(url);
|
|
193
|
+
assertClientHostAllowed(env, parsed, label);
|
|
194
|
+
await assertHostnameResolvesPublic(parsed, label);
|
|
195
|
+
|
|
17
196
|
const ctl = new AbortController();
|
|
18
197
|
const t = setTimeout(() => ctl.abort(), 3000);
|
|
19
198
|
try {
|
|
20
|
-
const response = await fetch(
|
|
21
|
-
|
|
199
|
+
const response = await fetch(url, {
|
|
200
|
+
signal: ctl.signal,
|
|
201
|
+
redirect: 'error',
|
|
202
|
+
headers: { accept: 'application/json' },
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) throw new Error(`${label} fetch failed: ${response.status}`);
|
|
22
205
|
const ctype = response.headers.get('content-type') || '';
|
|
23
|
-
if (!ctype.includes('application/json') && !ctype.includes('json'))
|
|
24
|
-
throw new Error(
|
|
25
|
-
|
|
206
|
+
if (!ctype.includes('application/json') && !ctype.includes('json')) {
|
|
207
|
+
throw new Error(`${label} must be JSON`);
|
|
208
|
+
}
|
|
209
|
+
const text = await readResponseTextBounded(response, label);
|
|
210
|
+
return JSON.parse(text || '{}');
|
|
26
211
|
} finally {
|
|
27
212
|
clearTimeout(t);
|
|
28
213
|
}
|
|
29
214
|
}
|
|
30
215
|
|
|
31
|
-
|
|
216
|
+
async function readResponseTextBounded(response: Response, label: string): Promise<string> {
|
|
217
|
+
if (!response.body) {
|
|
218
|
+
const text = await response.text();
|
|
219
|
+
if (text.length > MAX_CLIENT_JSON_BYTES) throw new Error(`${label} response too large`);
|
|
220
|
+
return text;
|
|
221
|
+
}
|
|
222
|
+
const reader = response.body.getReader();
|
|
223
|
+
const decoder = new TextDecoder();
|
|
224
|
+
let total = 0;
|
|
225
|
+
let text = '';
|
|
226
|
+
while (true) {
|
|
227
|
+
const { done, value } = await reader.read();
|
|
228
|
+
if (done) break;
|
|
229
|
+
total += value.byteLength;
|
|
230
|
+
if (total > MAX_CLIENT_JSON_BYTES) {
|
|
231
|
+
await reader.cancel().catch(() => {});
|
|
232
|
+
throw new Error(`${label} response too large`);
|
|
233
|
+
}
|
|
234
|
+
text += decoder.decode(value, { stream: true });
|
|
235
|
+
}
|
|
236
|
+
text += decoder.decode();
|
|
237
|
+
return text;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function assertHostnameResolvesPublic(url: URL, label: string): Promise<void> {
|
|
241
|
+
if (isIpLiteral(url.hostname)) {
|
|
242
|
+
if (isBlockedIpAddress(url.hostname)) throw new Error(`${label} resolves to blocked address`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const records = await resolveHostAddresses(url.hostname);
|
|
247
|
+
if (records.length === 0) {
|
|
248
|
+
throw new Error(`${label} hostname has no public address records`);
|
|
249
|
+
}
|
|
250
|
+
if (records.some(isBlockedIpAddress)) {
|
|
251
|
+
throw new Error(`${label} resolves to blocked address`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveHostAddresses(hostname: string): Promise<string[]> {
|
|
256
|
+
const records: string[] = [];
|
|
257
|
+
for (const type of ['A', 'AAAA']) {
|
|
258
|
+
const url = new URL('https://cloudflare-dns.com/dns-query');
|
|
259
|
+
url.searchParams.set('name', hostname);
|
|
260
|
+
url.searchParams.set('type', type);
|
|
261
|
+
const response = await fetch(url.toString(), {
|
|
262
|
+
headers: { accept: 'application/dns-json' },
|
|
263
|
+
redirect: 'error',
|
|
264
|
+
});
|
|
265
|
+
if (!response.ok) continue;
|
|
266
|
+
const body = await response.json().catch(() => null) as any;
|
|
267
|
+
const answers = Array.isArray(body?.Answer) ? body.Answer : [];
|
|
268
|
+
for (const answer of answers) {
|
|
269
|
+
if ((answer?.type === 1 || answer?.type === 28) && typeof answer.data === 'string') {
|
|
270
|
+
records.push(answer.data);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return records;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function fetchClientMetadata(env: Env, client_id: string): Promise<OAuthClientMetadata> {
|
|
278
|
+
const metadata = await safeFetchJson(env, client_id, 'client metadata');
|
|
279
|
+
return validateClientMetadataShape(metadata, client_id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function validateClientMetadataShape(metadata: any, clientId: string): OAuthClientMetadata {
|
|
283
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
284
|
+
throw new Error('client metadata must be an object');
|
|
285
|
+
}
|
|
286
|
+
if (metadata.client_id !== clientId) {
|
|
287
|
+
throw new Error('client_id mismatch');
|
|
288
|
+
}
|
|
289
|
+
if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) {
|
|
290
|
+
throw new Error('redirect_uris required');
|
|
291
|
+
}
|
|
292
|
+
const redirect_uris = metadata.redirect_uris;
|
|
293
|
+
if (!redirect_uris.every((uri: unknown) => typeof uri === 'string' && isAllowedRedirectUri(uri))) {
|
|
294
|
+
throw new Error('redirect_uris contains unsupported URI');
|
|
295
|
+
}
|
|
296
|
+
if (metadata.dpop_bound_access_tokens !== true) {
|
|
297
|
+
throw new Error('client must require DPoP');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const method = metadata.token_endpoint_auth_method;
|
|
301
|
+
if (method !== 'none' && method !== 'private_key_jwt') {
|
|
302
|
+
throw new Error('unsupported token_endpoint_auth_method');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!Array.isArray(metadata.response_types) || !metadata.response_types.includes('code')) {
|
|
306
|
+
throw new Error('response_types must include code');
|
|
307
|
+
}
|
|
308
|
+
if (!Array.isArray(metadata.grant_types) || !metadata.grant_types.includes('authorization_code')) {
|
|
309
|
+
throw new Error('grant_types must include authorization_code');
|
|
310
|
+
}
|
|
311
|
+
if (!metadata.grant_types.includes('refresh_token')) {
|
|
312
|
+
throw new Error('grant_types must include refresh_token');
|
|
313
|
+
}
|
|
314
|
+
if (typeof metadata.scope !== 'string' || !metadata.scope.split(' ').includes('atproto')) {
|
|
315
|
+
throw new Error('metadata scope must include atproto');
|
|
316
|
+
}
|
|
317
|
+
if (metadata.scope.split(' ').includes('openid')) {
|
|
318
|
+
throw new Error('openid scope is not supported');
|
|
319
|
+
}
|
|
320
|
+
if (metadata.jwks && metadata.jwks_uri) {
|
|
321
|
+
throw new Error('client metadata must not include both jwks and jwks_uri');
|
|
322
|
+
}
|
|
323
|
+
if (method === 'private_key_jwt' && !metadata.jwks && typeof metadata.jwks_uri !== 'string') {
|
|
324
|
+
throw new Error('private_key_jwt clients must provide jwks or jwks_uri');
|
|
325
|
+
}
|
|
326
|
+
if (metadata.jwks_uri && !isSafeFetchUrl(metadata.jwks_uri)) {
|
|
327
|
+
throw new Error('jwks_uri is not safe to fetch');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
client_id: metadata.client_id,
|
|
332
|
+
redirect_uris,
|
|
333
|
+
token_endpoint_auth_method: method,
|
|
334
|
+
grant_types: metadata.grant_types,
|
|
335
|
+
response_types: metadata.response_types,
|
|
336
|
+
scope: metadata.scope,
|
|
337
|
+
dpop_bound_access_tokens: true,
|
|
338
|
+
jwks: metadata.jwks,
|
|
339
|
+
jwks_uri: metadata.jwks_uri,
|
|
340
|
+
application_type: typeof metadata.application_type === 'string' ? metadata.application_type : undefined,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function validateParRequest(metadata: OAuthClientMetadata, request: {
|
|
345
|
+
response_type: string;
|
|
346
|
+
grant_type?: string;
|
|
347
|
+
redirect_uri: string;
|
|
348
|
+
scope: string;
|
|
349
|
+
code_challenge: string;
|
|
350
|
+
code_challenge_method: string;
|
|
351
|
+
}): void {
|
|
352
|
+
if (request.grant_type && request.grant_type !== 'authorization_code') {
|
|
353
|
+
throw new Error('unsupported grant_type');
|
|
354
|
+
}
|
|
355
|
+
if (request.response_type !== 'code') {
|
|
356
|
+
throw new Error('unsupported response_type');
|
|
357
|
+
}
|
|
358
|
+
if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri)) {
|
|
359
|
+
throw new Error('unsupported redirect_uri');
|
|
360
|
+
}
|
|
361
|
+
if (!metadata.redirect_uris.some((uri) => redirectUriMatches(uri, request.redirect_uri))) {
|
|
362
|
+
throw new Error('redirect_uri not registered');
|
|
363
|
+
}
|
|
364
|
+
const requestedScopes = request.scope.split(' ').filter(Boolean);
|
|
365
|
+
const allowedScopes = new Set(metadata.scope.split(' ').filter(Boolean));
|
|
366
|
+
if (!requestedScopes.length || !requestedScopes.includes('atproto')) {
|
|
367
|
+
throw new Error('invalid scope');
|
|
368
|
+
}
|
|
369
|
+
if (requestedScopes.includes('openid')) {
|
|
370
|
+
throw new Error('openid scope is not supported');
|
|
371
|
+
}
|
|
372
|
+
for (const scope of requestedScopes) {
|
|
373
|
+
if (!allowedScopes.has(scope)) {
|
|
374
|
+
throw new Error(`scope not allowed: ${scope}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!request.code_challenge || request.code_challenge_method !== 'S256') {
|
|
378
|
+
throw new Error('PKCE S256 required');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function resolveClientJwks(env: Env, metadata: OAuthClientMetadata): Promise<{ keys: JsonWebKey[] }> {
|
|
383
|
+
let jwks = metadata.jwks;
|
|
384
|
+
if (!jwks && metadata.jwks_uri) {
|
|
385
|
+
jwks = await safeFetchJson(env, metadata.jwks_uri, 'client jwks');
|
|
386
|
+
}
|
|
387
|
+
if (!jwks || typeof jwks !== 'object' || !Array.isArray((jwks as any).keys)) {
|
|
388
|
+
throw new Error('invalid JWKS');
|
|
389
|
+
}
|
|
390
|
+
const keys = (jwks as any).keys;
|
|
391
|
+
if (!keys.every((key: unknown) => key && typeof key === 'object')) {
|
|
392
|
+
throw new Error('invalid JWKS key');
|
|
393
|
+
}
|
|
394
|
+
return { keys };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function verifyClientAuthentication(
|
|
398
|
+
env: Env,
|
|
399
|
+
client_id: string,
|
|
400
|
+
issuerOrigin: string,
|
|
401
|
+
metadata: OAuthClientMetadata,
|
|
402
|
+
form: URLSearchParams,
|
|
403
|
+
): Promise<VerifiedClientAuth> {
|
|
404
|
+
if (metadata.token_endpoint_auth_method === 'none') {
|
|
405
|
+
if (form.get('client_assertion') || form.get('client_assertion_type')) {
|
|
406
|
+
throw new Error('public client must not send client_assertion');
|
|
407
|
+
}
|
|
408
|
+
return { method: 'none', keyId: null };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const client_assertion_type = form.get('client_assertion_type') || '';
|
|
412
|
+
const client_assertion = form.get('client_assertion') || '';
|
|
413
|
+
if (client_assertion_type !== CLIENT_ASSERTION_TYPE || !client_assertion) {
|
|
414
|
+
throw new Error('missing client assertion');
|
|
415
|
+
}
|
|
416
|
+
const jwks = await resolveClientJwks(env, metadata);
|
|
417
|
+
const result = await verifyClientAssertion(env, client_id, issuerOrigin, client_assertion, jwks);
|
|
418
|
+
if (!result) {
|
|
419
|
+
throw new Error('invalid client assertion');
|
|
420
|
+
}
|
|
421
|
+
return { method: 'private_key_jwt', keyId: result.keyId };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function consumeClientAssertionJti(env: Env, clientId: string, jti: string, exp: number): Promise<boolean> {
|
|
425
|
+
const key = `oauth:client-assertion:jti:${clientId}:${jti}`;
|
|
426
|
+
await cleanupExpiredOAuthReplaySecrets(env, Math.floor(Date.now() / 1000));
|
|
427
|
+
return createSecretOnce(env, key, JSON.stringify({ exp }));
|
|
428
|
+
}
|
|
32
429
|
|
|
33
|
-
export async function verifyClientAssertion(
|
|
430
|
+
export async function verifyClientAssertion(
|
|
431
|
+
env: Env,
|
|
432
|
+
client_id: string,
|
|
433
|
+
issuerOrigin: string,
|
|
434
|
+
assertionJwt: string,
|
|
435
|
+
jwks: { keys: JsonWebKey[] },
|
|
436
|
+
): Promise<{ keyId: string } | null> {
|
|
34
437
|
try {
|
|
35
438
|
const [h, p] = assertionJwt.split('.');
|
|
36
|
-
if (!h || !p) return
|
|
439
|
+
if (!h || !p) return null;
|
|
37
440
|
const header = decodeProtectedHeader(assertionJwt) as any;
|
|
38
|
-
if (header.alg !== 'ES256') return
|
|
441
|
+
if (header.alg !== 'ES256') return null;
|
|
39
442
|
const keys: any[] = Array.isArray(jwks?.keys) ? jwks.keys : [];
|
|
40
|
-
if (!keys.length) return
|
|
443
|
+
if (!keys.length) return null;
|
|
41
444
|
const byKid = typeof header.kid === 'string' ? keys.find((k) => k.kid === header.kid) : null;
|
|
42
445
|
const candidates = byKid ? [byKid] : keys;
|
|
43
446
|
|
|
44
447
|
let payload: any | null = null;
|
|
448
|
+
let matchedKey: JsonWebKey | null = null;
|
|
45
449
|
for (const jwk of candidates) {
|
|
46
450
|
try {
|
|
47
451
|
const key = await importJWK(jwk as JoseJWK, 'ES256');
|
|
48
452
|
const verified = await compactVerify(assertionJwt, key);
|
|
49
453
|
payload = JSON.parse(new TextDecoder().decode(verified.payload));
|
|
454
|
+
matchedKey = jwk as JsonWebKey;
|
|
50
455
|
break;
|
|
51
456
|
} catch {
|
|
52
457
|
// Try the next JWK candidate; only the final no-payload check matters.
|
|
53
458
|
}
|
|
54
459
|
}
|
|
55
|
-
if (!payload) return
|
|
460
|
+
if (!payload || !matchedKey) return null;
|
|
56
461
|
|
|
57
462
|
const now = Math.floor(Date.now() / 1000);
|
|
58
|
-
if (payload.iss !== client_id) return
|
|
59
|
-
if (payload.sub !== client_id) return
|
|
60
|
-
if (payload.aud !== issuerOrigin) return
|
|
61
|
-
if (typeof payload.iat !== 'number' || now - payload.iat > 300) return
|
|
62
|
-
if (typeof payload.
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
|
|
463
|
+
if (payload.iss !== client_id) return null;
|
|
464
|
+
if (payload.sub !== client_id) return null;
|
|
465
|
+
if (payload.aud !== issuerOrigin) return null;
|
|
466
|
+
if (typeof payload.iat !== 'number' || now - payload.iat > 300 || payload.iat - now > 30) return null;
|
|
467
|
+
if (typeof payload.exp !== 'number' || payload.exp <= now || payload.exp - now > 300) return null;
|
|
468
|
+
if (typeof payload.jti !== 'string' || payload.jti.length < 8) return null;
|
|
469
|
+
if (!(await consumeClientAssertionJti(env, client_id, payload.jti, payload.exp))) return null;
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
keyId: typeof header.kid === 'string' ? header.kid : await jwkThumbprint(matchedKey),
|
|
473
|
+
};
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error instanceof DpopNonceError) throw error;
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function requireSameClientAuth(
|
|
481
|
+
env: Env,
|
|
482
|
+
clientId: string,
|
|
483
|
+
issuerOrigin: string,
|
|
484
|
+
metadata: OAuthClientMetadata,
|
|
485
|
+
form: URLSearchParams,
|
|
486
|
+
expected: { method: string | null; keyId?: string | null },
|
|
487
|
+
): Promise<VerifiedClientAuth> {
|
|
488
|
+
const actual = await verifyClientAuthentication(env, clientId, issuerOrigin, metadata, form);
|
|
489
|
+
if (actual.method !== expected.method) {
|
|
490
|
+
throw new Error('client authentication method changed');
|
|
491
|
+
}
|
|
492
|
+
if (actual.method === 'private_key_jwt' && expected.keyId && actual.keyId !== expected.keyId) {
|
|
493
|
+
throw new Error('client authentication key changed');
|
|
66
494
|
}
|
|
495
|
+
return actual;
|
|
67
496
|
}
|