@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 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
- `PDS_OAUTH_CLIENT_HOSTS` is required for OAuth clients that use dynamic
399
- client metadata. It is a comma-separated allowlist of hostnames that this
400
- single-user PDS may fetch for client metadata and JWKS documents.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -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?: string;
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
- export function isAllowedRedirectUri(uri: string): boolean {
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
- if (!redirect_uris.every((uri: unknown) => typeof uri === 'string' && isAllowedRedirectUri(uri))) {
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: typeof metadata.application_type === 'string' ? metadata.application_type : undefined,
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
- 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 };
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 { fetchClientMetadata, requireSameClientAuth } from '../../lib/oauth/clients';
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 requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
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 requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
48
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
50
49
  method: session.clientAuthMethod,
51
50
  keyId: session.clientAuthKeyId,
52
51
  });
@@ -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
- const clientMeta = await fetchClientMetadata(env, client_id).catch((error) => {
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
- const clientMeta = await fetchClientMetadata(env, client_id).catch((error) => {
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