@alteran/astro 0.7.3 → 0.7.5
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 +3 -4
- package/package.json +1 -1
- package/src/lib/oauth/clients.ts +75 -31
- package/src/pages/oauth/revoke.ts +3 -4
- package/src/pages/oauth/token.ts +7 -12
package/README.md
CHANGED
|
@@ -392,12 +392,11 @@ USER_PASSWORD=your-password
|
|
|
392
392
|
REFRESH_TOKEN=your-access-secret
|
|
393
393
|
REFRESH_TOKEN_SECRET=your-refresh-secret
|
|
394
394
|
PDS_SEQ_WINDOW=512
|
|
395
|
-
PDS_OAUTH_CLIENT_HOSTS=client.example,another-client.example
|
|
396
395
|
```
|
|
397
396
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
397
|
+
OAuth client metadata and JWKS documents are fetched dynamically from public
|
|
398
|
+
HTTPS URLs using hardened fetch checks: no redirects, public DNS only, size
|
|
399
|
+
limits, and timeouts.
|
|
401
400
|
|
|
402
401
|
### 3. Run Database Migration
|
|
403
402
|
```bash
|
package/package.json
CHANGED
package/src/lib/oauth/clients.ts
CHANGED
|
@@ -8,6 +8,7 @@ const MAX_CLIENT_JSON_BYTES = 128 * 1024;
|
|
|
8
8
|
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
9
9
|
|
|
10
10
|
export type ClientAuthMethod = 'none' | 'private_key_jwt';
|
|
11
|
+
export type OAuthApplicationType = 'web' | 'native';
|
|
11
12
|
|
|
12
13
|
export type OAuthClientMetadata = {
|
|
13
14
|
client_id: string;
|
|
@@ -19,7 +20,7 @@ export type OAuthClientMetadata = {
|
|
|
19
20
|
dpop_bound_access_tokens: true;
|
|
20
21
|
jwks?: { keys: JsonWebKey[] };
|
|
21
22
|
jwks_uri?: string;
|
|
22
|
-
application_type
|
|
23
|
+
application_type: OAuthApplicationType;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
export type VerifiedClientAuth = {
|
|
@@ -27,26 +28,6 @@ export type VerifiedClientAuth = {
|
|
|
27
28
|
keyId: string | null;
|
|
28
29
|
};
|
|
29
30
|
|
|
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
31
|
function isIpLiteral(hostname: string): boolean {
|
|
51
32
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
52
33
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return true;
|
|
@@ -153,16 +134,52 @@ function isLoopbackHostname(hostname: string): boolean {
|
|
|
153
134
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
154
135
|
}
|
|
155
136
|
|
|
156
|
-
|
|
137
|
+
function reverseDomain(hostname: string): string {
|
|
138
|
+
return hostname
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.split('.')
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.reverse()
|
|
143
|
+
.join('.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isNativePrivateUseRedirect(url: URL, uri: string, clientId: string | undefined): boolean {
|
|
147
|
+
if (!clientId) return false;
|
|
148
|
+
let clientUrl: URL;
|
|
149
|
+
try {
|
|
150
|
+
clientUrl = new URL(clientId);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (clientUrl.protocol !== 'https:') return false;
|
|
155
|
+
|
|
156
|
+
const scheme = url.protocol.slice(0, -1);
|
|
157
|
+
if (!scheme || scheme !== reverseDomain(clientUrl.hostname)) return false;
|
|
158
|
+
if (!uri.startsWith(`${scheme}:/`) || uri.startsWith(`${scheme}://`)) return false;
|
|
159
|
+
if (url.username || url.password || url.hash || url.host) return false;
|
|
160
|
+
return url.pathname.startsWith('/');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isAllowedRedirectUri(
|
|
164
|
+
uri: string,
|
|
165
|
+
opts: { applicationType?: OAuthApplicationType; clientId?: string } = {},
|
|
166
|
+
): boolean {
|
|
157
167
|
try {
|
|
158
168
|
const url = new URL(uri);
|
|
159
169
|
if (url.username || url.password || url.hash) return false;
|
|
160
170
|
if (url.protocol === 'https:') {
|
|
171
|
+
if (opts.applicationType === 'native' && opts.clientId) {
|
|
172
|
+
const clientUrl = new URL(opts.clientId);
|
|
173
|
+
if (url.origin !== clientUrl.origin) return false;
|
|
174
|
+
}
|
|
161
175
|
return !isBlockedHost(url.hostname);
|
|
162
176
|
}
|
|
163
177
|
if (url.protocol === 'http:') {
|
|
164
178
|
return isLoopbackHostname(url.hostname);
|
|
165
179
|
}
|
|
180
|
+
if (opts.applicationType === 'native') {
|
|
181
|
+
return isNativePrivateUseRedirect(url, uri, opts.clientId);
|
|
182
|
+
}
|
|
166
183
|
return false;
|
|
167
184
|
} catch {
|
|
168
185
|
return false;
|
|
@@ -190,7 +207,6 @@ export async function safeFetchJson(env: Env, url: string, label: string): Promi
|
|
|
190
207
|
}
|
|
191
208
|
|
|
192
209
|
const parsed = new URL(url);
|
|
193
|
-
assertClientHostAllowed(env, parsed, label);
|
|
194
210
|
await assertHostnameResolvesPublic(parsed, label);
|
|
195
211
|
|
|
196
212
|
const ctl = new AbortController();
|
|
@@ -290,14 +306,21 @@ export function validateClientMetadataShape(metadata: any, clientId: string): OA
|
|
|
290
306
|
throw new Error('redirect_uris required');
|
|
291
307
|
}
|
|
292
308
|
const redirect_uris = metadata.redirect_uris;
|
|
293
|
-
|
|
309
|
+
const application_type = metadata.application_type ?? 'web';
|
|
310
|
+
if (application_type !== 'web' && application_type !== 'native') {
|
|
311
|
+
throw new Error('unsupported application_type');
|
|
312
|
+
}
|
|
313
|
+
if (!redirect_uris.every((uri: unknown) => (
|
|
314
|
+
typeof uri === 'string' &&
|
|
315
|
+
isAllowedRedirectUri(uri, { applicationType: application_type, clientId })
|
|
316
|
+
))) {
|
|
294
317
|
throw new Error('redirect_uris contains unsupported URI');
|
|
295
318
|
}
|
|
296
319
|
if (metadata.dpop_bound_access_tokens !== true) {
|
|
297
320
|
throw new Error('client must require DPoP');
|
|
298
321
|
}
|
|
299
322
|
|
|
300
|
-
const method = metadata.token_endpoint_auth_method;
|
|
323
|
+
const method = metadata.token_endpoint_auth_method ?? 'none';
|
|
301
324
|
if (method !== 'none' && method !== 'private_key_jwt') {
|
|
302
325
|
throw new Error('unsupported token_endpoint_auth_method');
|
|
303
326
|
}
|
|
@@ -337,7 +360,7 @@ export function validateClientMetadataShape(metadata: any, clientId: string): OA
|
|
|
337
360
|
dpop_bound_access_tokens: true,
|
|
338
361
|
jwks: metadata.jwks,
|
|
339
362
|
jwks_uri: metadata.jwks_uri,
|
|
340
|
-
application_type
|
|
363
|
+
application_type,
|
|
341
364
|
};
|
|
342
365
|
}
|
|
343
366
|
|
|
@@ -355,7 +378,10 @@ export function validateParRequest(metadata: OAuthClientMetadata, request: {
|
|
|
355
378
|
if (request.response_type !== 'code') {
|
|
356
379
|
throw new Error('unsupported response_type');
|
|
357
380
|
}
|
|
358
|
-
if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri
|
|
381
|
+
if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri, {
|
|
382
|
+
applicationType: metadata.application_type,
|
|
383
|
+
clientId: metadata.client_id,
|
|
384
|
+
})) {
|
|
359
385
|
throw new Error('unsupported redirect_uri');
|
|
360
386
|
}
|
|
361
387
|
if (!metadata.redirect_uris.some((uri) => redirectUriMatches(uri, request.redirect_uri))) {
|
|
@@ -402,10 +428,7 @@ export async function verifyClientAuthentication(
|
|
|
402
428
|
form: URLSearchParams,
|
|
403
429
|
): Promise<VerifiedClientAuth> {
|
|
404
430
|
if (metadata.token_endpoint_auth_method === 'none') {
|
|
405
|
-
|
|
406
|
-
throw new Error('public client must not send client_assertion');
|
|
407
|
-
}
|
|
408
|
-
return { method: 'none', keyId: null };
|
|
431
|
+
return verifyPublicClientAuthentication(form);
|
|
409
432
|
}
|
|
410
433
|
|
|
411
434
|
const client_assertion_type = form.get('client_assertion_type') || '';
|
|
@@ -421,6 +444,27 @@ export async function verifyClientAuthentication(
|
|
|
421
444
|
return { method: 'private_key_jwt', keyId: result.keyId };
|
|
422
445
|
}
|
|
423
446
|
|
|
447
|
+
export function verifyPublicClientAuthentication(form: URLSearchParams): VerifiedClientAuth {
|
|
448
|
+
if (form.get('client_assertion') || form.get('client_assertion_type')) {
|
|
449
|
+
throw new Error('public client must not send client_assertion');
|
|
450
|
+
}
|
|
451
|
+
return { method: 'none', keyId: null };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function requireStoredClientAuthentication(
|
|
455
|
+
env: Env,
|
|
456
|
+
clientId: string,
|
|
457
|
+
issuerOrigin: string,
|
|
458
|
+
form: URLSearchParams,
|
|
459
|
+
expected: { method: string | null; keyId?: string | null },
|
|
460
|
+
): Promise<VerifiedClientAuth> {
|
|
461
|
+
if (expected.method === 'none') {
|
|
462
|
+
return verifyPublicClientAuthentication(form);
|
|
463
|
+
}
|
|
464
|
+
const metadata = await fetchClientMetadata(env, clientId);
|
|
465
|
+
return requireSameClientAuth(env, clientId, issuerOrigin, metadata, form, expected);
|
|
466
|
+
}
|
|
467
|
+
|
|
424
468
|
async function consumeClientAssertionJti(env: Env, clientId: string, jti: string, exp: number): Promise<boolean> {
|
|
425
469
|
const key = `oauth:client-assertion:jti:${clientId}:${jti}`;
|
|
426
470
|
await cleanupExpiredOAuthReplaySecrets(env, Math.floor(Date.now() / 1000));
|
|
@@ -4,7 +4,7 @@ import { consumeDpopVerificationJti, verifyDpop, dpopErrorResponse } from '../..
|
|
|
4
4
|
import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
5
5
|
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
6
6
|
import { verifyAccessToken, verifyRefreshToken } from '../../lib/session-tokens';
|
|
7
|
-
import {
|
|
7
|
+
import { requireStoredClientAuthentication } from '../../lib/oauth/clients';
|
|
8
8
|
import { getOAuthSession, getRefreshToken, revokeOAuthSession, revokeRefreshToken } from '../../db/account';
|
|
9
9
|
|
|
10
10
|
export const prerender = false;
|
|
@@ -21,7 +21,6 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const issuer = publicPdsOrigin(env, request);
|
|
24
|
-
const clientMeta = await fetchClientMetadata(env, client_id);
|
|
25
24
|
|
|
26
25
|
const refresh = await verifyRefreshToken(env, token, { ignoreExpiration: true }).catch(() => null);
|
|
27
26
|
if (refresh?.decoded?.jti) {
|
|
@@ -29,7 +28,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
29
28
|
if (stored?.tokenKind === 'oauth' && stored.oauthSessionId) {
|
|
30
29
|
const session = await getOAuthSession(env, stored.oauthSessionId);
|
|
31
30
|
if (session && session.clientId === client_id && session.dpopJkt === dpop.jkt) {
|
|
32
|
-
await
|
|
31
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
33
32
|
method: session.clientAuthMethod,
|
|
34
33
|
keyId: session.clientAuthKeyId,
|
|
35
34
|
});
|
|
@@ -46,7 +45,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
46
45
|
if (typeof sessionId === 'string') {
|
|
47
46
|
const session = await getOAuthSession(env, sessionId);
|
|
48
47
|
if (session && session.clientId === client_id && session.dpopJkt === dpop.jkt) {
|
|
49
|
-
await
|
|
48
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
50
49
|
method: session.clientAuthMethod,
|
|
51
50
|
keyId: session.clientAuthKeyId,
|
|
52
51
|
});
|
package/src/pages/oauth/token.ts
CHANGED
|
@@ -5,10 +5,7 @@ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
|
5
5
|
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
6
6
|
import { consumeCode } from '../../lib/oauth/store';
|
|
7
7
|
import { issueSessionTokens, verifyRefreshToken, verifyAccessToken } from '../../lib/session-tokens';
|
|
8
|
-
import {
|
|
9
|
-
fetchClientMetadata,
|
|
10
|
-
requireSameClientAuth,
|
|
11
|
-
} from '../../lib/oauth/clients';
|
|
8
|
+
import { requireStoredClientAuthentication } from '../../lib/oauth/clients';
|
|
12
9
|
import {
|
|
13
10
|
createOAuthSession,
|
|
14
11
|
getOAuthSession,
|
|
@@ -48,12 +45,11 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
48
45
|
if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
|
|
49
46
|
if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
throw new Error(`Client metadata fetch failed: ${errorMessage(error) ?? error}`);
|
|
53
|
-
});
|
|
54
|
-
await requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
|
|
48
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
55
49
|
method: rec.clientAuthMethod,
|
|
56
50
|
keyId: rec.clientAuthKeyId ?? null,
|
|
51
|
+
}).catch((error) => {
|
|
52
|
+
throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
57
53
|
});
|
|
58
54
|
await consumeDpopVerificationJti(env, ver);
|
|
59
55
|
|
|
@@ -138,12 +134,11 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
138
134
|
if (client_id !== session.clientId) return jsonError('invalid_grant', 'client_id mismatch');
|
|
139
135
|
if (ver.jkt !== session.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
|
|
140
136
|
|
|
141
|
-
|
|
142
|
-
throw new Error(`Client metadata fetch failed: ${errorMessage(error) ?? error}`);
|
|
143
|
-
});
|
|
144
|
-
await requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
|
|
137
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
145
138
|
method: session.clientAuthMethod,
|
|
146
139
|
keyId: session.clientAuthKeyId,
|
|
140
|
+
}).catch((error) => {
|
|
141
|
+
throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
147
142
|
});
|
|
148
143
|
await consumeDpopVerificationJti(env, ver);
|
|
149
144
|
|