@alteran/astro 0.3.8 → 0.5.2

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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -30
  3. package/index.js +34 -28
  4. package/migrations/0007_bored_spitfire.sql +26 -0
  5. package/migrations/0008_furry_ozymandias.sql +2 -0
  6. package/migrations/meta/0007_snapshot.json +534 -0
  7. package/migrations/meta/0008_snapshot.json +548 -0
  8. package/migrations/meta/_journal.json +14 -0
  9. package/package.json +10 -9
  10. package/src/app.ts +8 -4
  11. package/src/db/account.ts +25 -6
  12. package/src/db/dal.ts +34 -23
  13. package/src/db/repo.ts +35 -35
  14. package/src/db/schema.ts +5 -1
  15. package/src/db/seed.ts +5 -13
  16. package/src/entrypoints/server.ts +2 -22
  17. package/src/handlers/root.ts +4 -4
  18. package/src/lib/account-state.ts +156 -0
  19. package/src/lib/actor.ts +28 -12
  20. package/src/lib/appview/auth-policy.ts +66 -0
  21. package/src/lib/appview/did-resolver.ts +233 -0
  22. package/src/lib/appview/proxy.ts +221 -0
  23. package/src/lib/appview/service-config.ts +61 -0
  24. package/src/lib/appview/service-jwt.ts +93 -0
  25. package/src/lib/appview/types.ts +25 -0
  26. package/src/lib/appview.ts +5 -532
  27. package/src/lib/auth-errors.ts +24 -0
  28. package/src/lib/auth.ts +63 -15
  29. package/src/lib/blockstore-gc.ts +2 -1
  30. package/src/lib/cache.ts +30 -4
  31. package/src/lib/chat.ts +14 -8
  32. package/src/lib/commit.ts +26 -36
  33. package/src/lib/config.ts +26 -15
  34. package/src/lib/did-document.ts +32 -0
  35. package/src/lib/errors.ts +54 -0
  36. package/src/lib/feed.ts +18 -19
  37. package/src/lib/firehose/frames.ts +87 -47
  38. package/src/lib/firehose/validation.ts +3 -3
  39. package/src/lib/jwt.ts +85 -177
  40. package/src/lib/labeler.ts +43 -30
  41. package/src/lib/logger.ts +4 -0
  42. package/src/lib/mst/block-map.ts +172 -0
  43. package/src/lib/mst/blockstore.ts +56 -93
  44. package/src/lib/mst/index.ts +1 -0
  45. package/src/lib/mst/leaf.ts +25 -0
  46. package/src/lib/mst/mst.ts +81 -237
  47. package/src/lib/mst/serialize.ts +97 -0
  48. package/src/lib/mst/types.ts +21 -0
  49. package/src/lib/oauth/clients.ts +67 -0
  50. package/src/lib/oauth/dpop-errors.ts +15 -0
  51. package/src/lib/oauth/dpop.ts +150 -0
  52. package/src/lib/oauth/resource.ts +199 -0
  53. package/src/lib/oauth/store.ts +77 -0
  54. package/src/lib/preferences.ts +9 -34
  55. package/src/lib/refresh-session.ts +161 -0
  56. package/src/lib/relay.ts +10 -8
  57. package/src/lib/secrets.ts +6 -7
  58. package/src/lib/sequencer.ts +12 -3
  59. package/src/lib/service-auth.ts +184 -0
  60. package/src/lib/session-tokens.ts +28 -76
  61. package/src/lib/streaming-car.ts +3 -0
  62. package/src/lib/tracing.ts +4 -3
  63. package/src/lib/util.ts +65 -15
  64. package/src/middleware.ts +1 -1
  65. package/src/pages/.well-known/did.json.ts +27 -30
  66. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  67. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  68. package/src/pages/debug/record.ts +1 -1
  69. package/src/pages/debug/sequencer.ts +28 -0
  70. package/src/pages/oauth/authorize.ts +78 -0
  71. package/src/pages/oauth/consent.ts +80 -0
  72. package/src/pages/oauth/par.ts +121 -0
  73. package/src/pages/oauth/token.ts +158 -0
  74. package/src/pages/xrpc/[...nsid].ts +61 -0
  75. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  76. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  77. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  78. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  79. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  80. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  81. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  82. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  83. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  84. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  85. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  86. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  87. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  88. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  89. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  90. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  91. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  92. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  93. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  94. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  95. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  96. package/src/pages/xrpc/com.atproto.server.createSession.ts +31 -11
  97. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  99. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  100. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  101. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +71 -22
  102. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  103. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  104. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  105. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  106. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  107. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  108. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  109. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  110. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +1 -1
  111. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  112. package/src/services/car.ts +207 -55
  113. package/src/services/r2-blob-store.ts +1 -1
  114. package/src/services/repo/blockstore-ops.ts +29 -0
  115. package/src/services/repo/operations.ts +133 -0
  116. package/src/services/repo-manager.ts +202 -253
  117. package/src/worker/runtime.ts +53 -8
  118. package/src/worker/sequencer/broadcast.ts +91 -0
  119. package/src/worker/sequencer/cid-helpers.ts +39 -0
  120. package/src/worker/sequencer/payload.ts +84 -0
  121. package/src/worker/sequencer/types.ts +36 -0
  122. package/src/worker/sequencer/upgrade.ts +141 -0
  123. package/src/worker/sequencer.ts +288 -412
  124. package/types/env.d.ts +15 -3
  125. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  126. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  127. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  128. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  129. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  130. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  131. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  132. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  133. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  134. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  135. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  136. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
@@ -1,6 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
2
3
  import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
- import { base58btc } from 'multiformats/bases/base58';
4
4
  import { resolveSecret } from '../../lib/secrets';
5
5
  import { Secp256k1Keypair } from '@atproto/crypto';
6
6
  import { formatMultikey } from '@atproto/crypto/dist/did';
@@ -18,35 +18,28 @@ export async function GET({ locals, request }: APIContext) {
18
18
  const hostname = env.PDS_HOSTNAME ?? new URL(request.url).hostname;
19
19
 
20
20
  let publicKeyMultibase: string | undefined;
21
-
22
- const serviceKeyHex = await resolveSecret(env.PDS_SERVICE_SIGNING_KEY_HEX as any);
23
- if (serviceKeyHex) {
24
- try {
25
- const keypair = await Secp256k1Keypair.import(serviceKeyHex.trim());
26
- publicKeyMultibase = formatMultikey(keypair.jwtAlg, keypair.publicKeyBytes());
27
- } catch (error) {
28
- console.warn('Failed to encode service signing key', error);
29
- }
30
- }
31
-
32
- if (!publicKeyMultibase) {
33
- const repoPubKeyB64 = await resolveSecret((env as any).REPO_SIGNING_KEY_PUBLIC);
34
- if (repoPubKeyB64) {
35
- try {
36
- const bin = atob(repoPubKeyB64.replace(/\s+/g, ''));
37
- const raw = new Uint8Array(bin.length);
38
- for (let i = 0; i < bin.length; i++) raw[i] = bin.charCodeAt(i);
39
- if (raw.byteLength === 32) {
40
- const prefixed = new Uint8Array(2 + raw.byteLength);
41
- prefixed[0] = 0xed;
42
- prefixed[1] = 0x01;
43
- prefixed.set(raw, 2);
44
- publicKeyMultibase = base58btc.encode(prefixed);
45
- }
46
- } catch (error) {
47
- console.warn('Failed to encode repo signing key', error);
21
+ let signingKeyError: string | undefined;
22
+ try {
23
+ const signingKey = await resolveSecret((env as any).REPO_SIGNING_KEY);
24
+ if (!signingKey) {
25
+ signingKeyError = 'REPO_SIGNING_KEY not configured';
26
+ console.warn('did.json: REPO_SIGNING_KEY not configured');
27
+ } else {
28
+ const cleaned = signingKey.trim();
29
+ let kp: Secp256k1Keypair;
30
+ if (/^[0-9a-fA-F]{64}$/.test(cleaned)) {
31
+ kp = await Secp256k1Keypair.import(cleaned);
32
+ } else {
33
+ const bin = atob(cleaned.replace(/\s+/g, ''));
34
+ const bytes = new Uint8Array(bin.length);
35
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
36
+ kp = await Secp256k1Keypair.import(bytes);
48
37
  }
38
+ publicKeyMultibase = formatMultikey(kp.jwtAlg, kp.publicKeyBytes());
49
39
  }
40
+ } catch (error) {
41
+ signingKeyError = `Failed to process REPO_SIGNING_KEY: ${errorMessage(error) || error}`;
42
+ console.error('did.json: Failed to process REPO_SIGNING_KEY:', error);
50
43
  }
51
44
 
52
45
  const verificationMethods = publicKeyMultibase
@@ -60,7 +53,7 @@ export async function GET({ locals, request }: APIContext) {
60
53
  ]
61
54
  : [];
62
55
 
63
- const didDocument = {
56
+ const didDocument: Record<string, unknown> = {
64
57
  '@context': [
65
58
  'https://www.w3.org/ns/did/v1',
66
59
  'https://w3id.org/security/multikey/v1',
@@ -77,6 +70,11 @@ export async function GET({ locals, request }: APIContext) {
77
70
  ],
78
71
  };
79
72
 
73
+ // Add debug info if signing key has issues (only visible in dev/debug)
74
+ if (signingKeyError && (env as any).ENVIRONMENT !== 'production') {
75
+ didDocument._debug = { signingKeyError };
76
+ }
77
+
80
78
  return new Response(JSON.stringify(didDocument, null, 2), {
81
79
  headers: {
82
80
  'Content-Type': 'application/json',
@@ -86,4 +84,3 @@ export async function GET({ locals, request }: APIContext) {
86
84
  CACHE_CONFIGS.DID_DOCUMENT,
87
85
  );
88
86
  }
89
-
@@ -0,0 +1,31 @@
1
+ import type { APIContext } from 'astro';
2
+ import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals, request }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ return withCache(
9
+ request,
10
+ async () => {
11
+ const url = new URL(request.url);
12
+ const origin = `${url.protocol}//${url.host}`;
13
+ const json = {
14
+ issuer: origin,
15
+ pushed_authorization_request_endpoint: `${origin}/oauth/par`,
16
+ authorization_endpoint: `${origin}/oauth/authorize`,
17
+ token_endpoint: `${origin}/oauth/token`,
18
+ scopes_supported: 'atproto transition:generic',
19
+ response_types_supported: ['code'],
20
+ grant_types_supported: ['authorization_code', 'refresh_token'],
21
+ code_challenge_methods_supported: ['S256'],
22
+ token_endpoint_auth_methods_supported: ['none', 'private_key_jwt'],
23
+ dpop_signing_alg_values_supported: ['ES256'],
24
+ };
25
+ return new Response(JSON.stringify(json, null, 2), {
26
+ headers: { 'Content-Type': 'application/json' },
27
+ });
28
+ },
29
+ CACHE_CONFIGS.WELL_KNOWN,
30
+ );
31
+ }
@@ -0,0 +1,22 @@
1
+ import type { APIContext } from 'astro';
2
+ import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ request }: APIContext) {
7
+ return withCache(
8
+ request,
9
+ async () => {
10
+ const url = new URL(request.url);
11
+ const origin = `${url.protocol}//${url.host}`;
12
+ const json = {
13
+ authorization_servers: [origin],
14
+ };
15
+ return new Response(JSON.stringify(json, null, 2), {
16
+ headers: { 'Content-Type': 'application/json' },
17
+ });
18
+ },
19
+ CACHE_CONFIGS.WELL_KNOWN,
20
+ );
21
+ }
22
+
@@ -21,7 +21,7 @@ export async function POST({ locals, request }: APIContext) {
21
21
  const uri = body.uri;
22
22
  if (!uri) return new Response('missing uri', { status: 400 });
23
23
 
24
- const did = env.PDS_DID ?? 'did:example:single-user';
24
+ const did = env.PDS_DID as string;
25
25
  const row = {
26
26
  uri,
27
27
  did,
@@ -0,0 +1,28 @@
1
+ import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals }: APIContext) {
7
+ const { env } = locals.runtime;
8
+
9
+ if (!env.SEQUENCER) {
10
+ return new Response(JSON.stringify({ error: 'SequencerNotConfigured' }), {
11
+ status: 503,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ });
14
+ }
15
+
16
+ try {
17
+ const id = env.SEQUENCER.idFromName('default');
18
+ const stub = env.SEQUENCER.get(id);
19
+ const response = await stub.fetch(new Request('http://internal/metrics') as any);
20
+ const text = await response.text();
21
+ return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } });
22
+ } catch (e) {
23
+ return new Response(JSON.stringify({ error: 'InternalError', message: String(errorMessage(e) || e) }), {
24
+ status: 500,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,78 @@
1
+ import type { APIContext } from 'astro';
2
+ import { loadPar, saveCode, deletePar } from '../../lib/oauth/store';
3
+
4
+ export const prerender = false;
5
+
6
+ function parseRequestUri(u: string): string | null {
7
+ const p = 'urn:ietf:params:oauth:request_uri:';
8
+ if (!u || !u.startsWith(p)) return null;
9
+ const id = u.slice(p.length);
10
+ return /^[A-Za-z0-9]+$/.test(id) ? id : null;
11
+ }
12
+
13
+ export async function GET({ locals, request }: APIContext) {
14
+ const { env } = locals.runtime;
15
+ const url = new URL(request.url);
16
+ const request_uri = url.searchParams.get('request_uri') || '';
17
+ const client_id = url.searchParams.get('client_id') || '';
18
+ const deny = url.searchParams.get('deny') === '1';
19
+ const prompt = url.searchParams.get('prompt') || '';
20
+
21
+ const id = parseRequestUri(request_uri);
22
+ if (!id) {
23
+ return new Response('invalid request_uri', { status: 400 });
24
+ }
25
+ const par = await loadPar(env, id);
26
+ if (!par) {
27
+ return new Response('request expired or not found', { status: 400 });
28
+ }
29
+ if (client_id && client_id !== par.client_id) {
30
+ return new Response('client_id mismatch', { status: 400 });
31
+ }
32
+
33
+ if (deny) {
34
+ const redirectDeny = new URL(par.redirect_uri);
35
+ redirectDeny.searchParams.set('state', par.state);
36
+ redirectDeny.searchParams.set('error', 'access_denied');
37
+ return new Response(null, { status: 302, headers: { Location: redirectDeny.toString() } });
38
+ }
39
+
40
+ const requireConsent = String((env as any).PDS_REQUIRE_CONSENT ?? '1') !== '0' || prompt === 'consent';
41
+ if (requireConsent && prompt !== 'none') {
42
+ const consentUrl = new URL('/oauth/consent', `${url.protocol}//${url.host}`);
43
+ consentUrl.searchParams.set('request_uri', request_uri);
44
+ consentUrl.searchParams.set('client_id', par.client_id);
45
+ return new Response(null, { status: 302, headers: { Location: consentUrl.toString() } });
46
+ }
47
+
48
+ // TODO: implement user authentication + consent UI.
49
+ // For single-user PDS, auto-approve using configured DID.
50
+ const did = String((env as any).PDS_DID ?? 'did:example:single-user');
51
+
52
+ // Issue a short-lived authorization code
53
+ const code = crypto.randomUUID().replace(/-/g, '');
54
+ const now = Math.floor(Date.now() / 1000);
55
+ await saveCode(env, code, {
56
+ code,
57
+ client_id: par.client_id,
58
+ redirect_uri: par.redirect_uri,
59
+ code_challenge: par.code_challenge,
60
+ scope: par.scope,
61
+ dpopJkt: par.dpopJkt,
62
+ did,
63
+ createdAt: now,
64
+ expiresAt: now + 600, // 10 minutes
65
+ used: false,
66
+ });
67
+ await deletePar(env, id);
68
+
69
+ const redirect = new URL(par.redirect_uri);
70
+ redirect.searchParams.set('state', par.state);
71
+ redirect.searchParams.set('iss', `${url.protocol}//${url.host}`);
72
+ redirect.searchParams.set('code', code);
73
+
74
+ return new Response(null, {
75
+ status: 302,
76
+ headers: { Location: redirect.toString() },
77
+ });
78
+ }
@@ -0,0 +1,80 @@
1
+ import type { APIContext } from 'astro';
2
+ import { loadPar } from '../../lib/oauth/store';
3
+ import { fetchClientMetadata } from '../../lib/oauth/clients';
4
+
5
+ export const prerender = false;
6
+
7
+ function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'} as any)[c]); }
8
+
9
+ export async function GET({ locals, request }: APIContext) {
10
+ const url = new URL(request.url);
11
+ const request_uri = url.searchParams.get('request_uri') || '';
12
+ const client_id = url.searchParams.get('client_id') || '';
13
+
14
+ const id = request_uri.replace('urn:ietf:params:oauth:request_uri:', '');
15
+ if (!id) return new Response('invalid request_uri', { status: 400 });
16
+ const par = await loadPar(locals.runtime.env, id);
17
+ if (!par) return new Response('request expired or not found', { status: 400 });
18
+ if (client_id && par.client_id !== client_id) return new Response('client_id mismatch', { status: 400 });
19
+
20
+ let meta: any = null;
21
+ try {
22
+ meta = await fetchClientMetadata(par.client_id);
23
+ } catch {
24
+ // Client metadata is decorative on this page; the consent form still renders.
25
+ }
26
+ const clientName = esc(meta?.client_name || new URL(par.client_id).host);
27
+ const logo = typeof meta?.logo_uri === 'string' ? meta.logo_uri : '';
28
+ const scopes = par.scope.split(' ').filter(Boolean);
29
+
30
+ const allowUrl = new URL('/oauth/authorize', `${url.protocol}//${url.host}`);
31
+ allowUrl.searchParams.set('request_uri', request_uri);
32
+ allowUrl.searchParams.set('client_id', par.client_id);
33
+
34
+ const denyUrl = new URL(par.redirect_uri);
35
+ denyUrl.searchParams.set('state', par.state);
36
+ denyUrl.searchParams.set('error', 'access_denied');
37
+
38
+ const html = `<!doctype html>
39
+ <html>
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
43
+ <title>Authorize ${clientName}</title>
44
+ <style>
45
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 2rem; color: #222; }
46
+ .card { max-width: 560px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
47
+ .client { display: flex; gap: 12px; align-items: center; }
48
+ img.logo { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; }
49
+ ul { padding-left: 1.2rem; }
50
+ .actions { display: flex; gap: 12px; margin-top: 1rem; }
51
+ a.btn { display: inline-block; padding: 8px 14px; border-radius: 6px; text-decoration: none; }
52
+ a.primary { background: #0a66ff; color: #fff; }
53
+ a.secondary { background: #eee; color: #333; }
54
+ .scope { background: #f5f5f7; display: inline-block; padding: 2px 8px; border-radius: 999px; margin-right: 6px; font-size: 12px; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="card">
59
+ <div class="client">
60
+ ${logo ? `<img class="logo" src="${esc(logo)}" alt="" />` : ''}
61
+ <div>
62
+ <div style="font-weight:600;">${clientName}</div>
63
+ <div style="color:#555; font-size: 12px;">${esc(par.client_id)}</div>
64
+ </div>
65
+ </div>
66
+ <p style="margin-top:1rem;">This app is requesting:</p>
67
+ <div>
68
+ ${scopes.map((s) => `<span class="scope">${esc(s)}</span>`).join(' ')}
69
+ </div>
70
+ <div class="actions">
71
+ <a class="btn primary" href="${allowUrl.toString()}">Allow</a>
72
+ <a class="btn secondary" href="${denyUrl.toString()}">Deny</a>
73
+ </div>
74
+ </div>
75
+ </body>
76
+ </html>`;
77
+
78
+ return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
79
+ }
80
+
@@ -0,0 +1,121 @@
1
+ import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
3
+ import { getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
4
+ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
+ import { savePar } from '../../lib/oauth/store';
6
+ import { fetchClientMetadata, isHttpsUrl, verifyClientAssertion } from '../../lib/oauth/clients';
7
+
8
+ export const prerender = false;
9
+
10
+ export async function POST({ locals, request }: APIContext) {
11
+ const { env } = locals.runtime;
12
+
13
+ // Enforce DPoP with nonce; if missing or stale, return use_dpop_nonce
14
+ try {
15
+ const ver = await verifyDpop(env, request);
16
+
17
+ // Parse form-encoded body
18
+ const bodyText = await request.text();
19
+ const form = new URLSearchParams(bodyText);
20
+ const client_id = form.get('client_id') || '';
21
+ const response_type = form.get('response_type') || '';
22
+ const redirect_uri = form.get('redirect_uri') || '';
23
+ const scope = form.get('scope') || '';
24
+ const state = form.get('state') || '';
25
+ const code_challenge = form.get('code_challenge') || '';
26
+ const code_challenge_method = form.get('code_challenge_method') || '';
27
+ const login_hint = form.get('login_hint') || undefined;
28
+ const client_assertion_type = form.get('client_assertion_type') || '';
29
+ const client_assertion = form.get('client_assertion') || '';
30
+
31
+ if (!client_id || !isHttpsUrl(client_id)) {
32
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
33
+ }
34
+ if (response_type !== 'code') {
35
+ return new Response(JSON.stringify({ error: 'unsupported_response_type' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
36
+ }
37
+ if (!redirect_uri || !isHttpsUrl(redirect_uri)) {
38
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri must be https URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
39
+ }
40
+ if (!scope || !scope.split(' ').includes('atproto')) {
41
+ return new Response(JSON.stringify({ error: 'invalid_scope' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
42
+ }
43
+ if (!state) {
44
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
45
+ }
46
+ if (!code_challenge || code_challenge_method !== 'S256') {
47
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'PKCE (S256) required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
48
+ }
49
+
50
+ // Fetch and validate client metadata
51
+ let clientMeta: any = null;
52
+ try {
53
+ clientMeta = await fetchClientMetadata(client_id);
54
+ } catch (e) {
55
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
56
+ }
57
+
58
+ if (clientMeta?.client_id !== client_id) {
59
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id mismatch' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
60
+ }
61
+ const redirects: string[] = Array.isArray(clientMeta?.redirect_uris) ? clientMeta.redirect_uris : [];
62
+ if (!redirects.includes(redirect_uri)) {
63
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uri not registered' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
64
+ }
65
+ if (clientMeta?.dpop_bound_access_tokens !== true) {
66
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client must require DPoP' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
67
+ }
68
+ const url = new URL(request.url);
69
+ const issuerOrigin = `${url.protocol}//${url.host}`;
70
+ const authMethod = clientMeta?.token_endpoint_auth_method;
71
+ if (authMethod === 'private_key_jwt') {
72
+ // Load JWKS (inline or via URI)
73
+ let jwks = clientMeta?.jwks;
74
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
75
+ try {
76
+ const response = await fetch(clientMeta.jwks_uri);
77
+ jwks = await response.json();
78
+ } catch (e) {
79
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Failed to fetch jwks_uri' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
80
+ }
81
+ }
82
+ if (!jwks) {
83
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing JWKS' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
84
+ }
85
+ if (client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' || !client_assertion) {
86
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Missing client assertion' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
87
+ }
88
+ const ok = await verifyClientAssertion(client_id, issuerOrigin, client_assertion, jwks);
89
+ if (!ok) {
90
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'Invalid client assertion' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
91
+ }
92
+ }
93
+
94
+ const id = crypto.randomUUID().replace(/-/g, '');
95
+ const now = Math.floor(Date.now() / 1000);
96
+ const rec = {
97
+ client_id,
98
+ redirect_uri,
99
+ code_challenge,
100
+ code_challenge_method: 'S256' as const,
101
+ scope,
102
+ state,
103
+ login_hint,
104
+ dpopJkt: ver.jkt,
105
+ createdAt: now,
106
+ expiresAt: now + 300, // 5 minutes
107
+ };
108
+ await savePar(env, id, rec);
109
+
110
+ const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
111
+ const headers = new Headers({ 'Content-Type': 'application/json' });
112
+ setDpopNonceHeader(headers, await getAuthzNonce(env));
113
+ return new Response(JSON.stringify({ request_uri }), { status: 201, headers });
114
+ } catch (e) {
115
+ if (e instanceof DpopNonceError) {
116
+ return dpopErrorResponse(env, e);
117
+ }
118
+ const headers = new Headers({ 'Content-Type': 'application/json' });
119
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: errorMessage(e) ?? 'Unknown error' }), { status: 400, headers });
120
+ }
121
+ }
@@ -0,0 +1,158 @@
1
+ import type { APIContext } from 'astro';
2
+ import { errorMessage } from '../../lib/errors';
3
+ import { verifyDpop, dpopErrorResponse, getAuthzNonce } from '../../lib/oauth/dpop';
4
+ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
+ import { consumeCode } from '../../lib/oauth/store';
6
+ import { sha256b64url } from '../../lib/oauth/dpop';
7
+ import { issueSessionTokens, verifyRefreshToken, verifyAccessToken, computeGraceExpiry } from '../../lib/session-tokens';
8
+ import { fetchClientMetadata, verifyClientAssertion } from '../../lib/oauth/clients';
9
+ import { storeRefreshToken, getRefreshToken, markRefreshTokenRotated } from '../../db/account';
10
+
11
+ export const prerender = false;
12
+
13
+ export async function POST({ locals, request }: APIContext) {
14
+ const { env } = locals.runtime;
15
+
16
+ try {
17
+ const ver = await verifyDpop(env, request);
18
+
19
+ const form = new URLSearchParams(await request.text());
20
+ const grant_type = form.get('grant_type') || '';
21
+
22
+ if (grant_type === 'authorization_code') {
23
+ const code = form.get('code') || '';
24
+ const client_id = form.get('client_id') || '';
25
+ const redirect_uri = form.get('redirect_uri') || '';
26
+ const code_verifier = form.get('code_verifier') || '';
27
+ const client_assertion_type = form.get('client_assertion_type') || '';
28
+ const client_assertion = form.get('client_assertion') || '';
29
+
30
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
31
+ return jsonError('invalid_request', 'Missing parameters');
32
+ }
33
+
34
+ const rec = await consumeCode(env, code);
35
+ if (!rec) return jsonError('invalid_grant', 'Invalid or used code');
36
+ if (rec.client_id !== client_id) return jsonError('invalid_grant', 'client_id mismatch');
37
+ if (rec.redirect_uri !== redirect_uri) return jsonError('invalid_grant', 'redirect_uri mismatch');
38
+
39
+ const expected = await sha256b64url(code_verifier);
40
+ if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
41
+
42
+ if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
43
+
44
+ // If confidential client, verify assertion
45
+ let clientMeta: any = null;
46
+ try {
47
+ clientMeta = await fetchClientMetadata(client_id);
48
+ } catch {
49
+ // Public clients have no fetchable metadata; only confidential clients gate on it below.
50
+ }
51
+ if (clientMeta?.token_endpoint_auth_method === 'private_key_jwt') {
52
+ let jwks = clientMeta?.jwks;
53
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
54
+ const response = await fetch(clientMeta.jwks_uri);
55
+ jwks = await response.json();
56
+ }
57
+ const origin = `${new URL(request.url).protocol}//${new URL(request.url).host}`;
58
+ if (!client_assertion || client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
59
+ return jsonError('invalid_client', 'Missing client assertion');
60
+ const ok = await verifyClientAssertion(client_id, origin, client_assertion, jwks);
61
+ if (!ok) return jsonError('invalid_client', 'Invalid client assertion');
62
+ }
63
+
64
+ // Issue tokens bound to this DID and include DPoP cnf in access token
65
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, rec.did);
66
+ await storeRefreshToken(env, { id: refreshPayload.jti, did: rec.did, expiresAt: refreshExpiry, appPasswordName: null });
67
+
68
+ // Derive expires_in from access token
69
+ const payload = await verifyAccessToken(env, accessJwt).catch(() => null);
70
+ const now = Math.floor(Date.now() / 1000);
71
+ const expires_in = payload && typeof payload.exp === 'number' ? Math.max(0, payload.exp - now) : 7200;
72
+
73
+ const out = {
74
+ access_token: accessJwt,
75
+ token_type: 'DPoP',
76
+ expires_in,
77
+ refresh_token: refreshJwt,
78
+ scope: rec.scope,
79
+ sub: rec.did,
80
+ } as const;
81
+ const headers = new Headers({ 'Content-Type': 'application/json' });
82
+ headers.set('DPoP-Nonce', await getAuthzNonce(env));
83
+ return new Response(JSON.stringify(out), { status: 200, headers });
84
+ }
85
+
86
+ if (grant_type === 'refresh_token') {
87
+ const refresh_token = form.get('refresh_token') || '';
88
+ const client_id = form.get('client_id') || '';
89
+ const client_assertion_type = form.get('client_assertion_type') || '';
90
+ const client_assertion = form.get('client_assertion') || '';
91
+ if (!refresh_token) return jsonError('invalid_request', 'Missing refresh_token');
92
+
93
+ // If confidential client, verify assertion
94
+ if (client_id) {
95
+ let clientMeta: any = null;
96
+ try {
97
+ clientMeta = await fetchClientMetadata(client_id);
98
+ } catch {
99
+ // Public clients have no fetchable metadata; only confidential clients gate on it below.
100
+ }
101
+ if (clientMeta?.token_endpoint_auth_method === 'private_key_jwt') {
102
+ let jwks = clientMeta?.jwks;
103
+ if (!jwks && typeof clientMeta?.jwks_uri === 'string') {
104
+ const response = await fetch(clientMeta.jwks_uri);
105
+ jwks = await response.json();
106
+ }
107
+ const origin = `${new URL(request.url).protocol}//${new URL(request.url).host}`;
108
+ if (!client_assertion || client_assertion_type !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
109
+ return jsonError('invalid_client', 'Missing client assertion');
110
+ const ok = await verifyClientAssertion(client_id, origin, client_assertion, jwks);
111
+ if (!ok) return jsonError('invalid_client', 'Invalid client assertion');
112
+ }
113
+ }
114
+
115
+ const verification = await verifyRefreshToken(env, refresh_token).catch(() => null);
116
+ if (!verification || !verification.decoded) return jsonError('invalid_grant', 'Invalid refresh token');
117
+ const nowSec = Math.floor(Date.now() / 1000);
118
+ if (verification.decoded.exp <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
119
+
120
+ const stored = await getRefreshToken(env, verification.decoded.jti);
121
+ if (!stored) return jsonError('invalid_grant', 'Refresh token revoked');
122
+ if (stored.expiresAt <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
123
+ if (stored.did !== verification.decoded.sub) return jsonError('invalid_grant', 'Subject mismatch');
124
+
125
+ const did = stored.did;
126
+ // Rotate refresh, issue new pair
127
+ const { accessJwt, refreshJwt, refreshPayload, refreshExpiry } = await issueSessionTokens(env, did, { jti: stored.nextId ?? undefined });
128
+ await storeRefreshToken(env, { id: refreshPayload.jti, did, expiresAt: refreshExpiry, appPasswordName: stored.appPasswordName ?? null });
129
+ const graceExpiry = computeGraceExpiry(stored.expiresAt, nowSec);
130
+ await markRefreshTokenRotated(env, verification.decoded.jti, refreshPayload.jti, graceExpiry);
131
+
132
+ const payload = await verifyAccessToken(env, accessJwt).catch(() => null);
133
+ const expires_in = payload && typeof payload.exp === 'number' ? Math.max(0, payload.exp - nowSec) : 7200;
134
+
135
+ const out = {
136
+ access_token: accessJwt,
137
+ token_type: 'DPoP',
138
+ expires_in,
139
+ refresh_token: refreshJwt,
140
+ scope: 'atproto',
141
+ sub: did,
142
+ } as const;
143
+ const headers = new Headers({ 'Content-Type': 'application/json' });
144
+ headers.set('DPoP-Nonce', await getAuthzNonce(env));
145
+ return new Response(JSON.stringify(out), { status: 200, headers });
146
+ }
147
+
148
+ return jsonError('unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
149
+ } catch (e) {
150
+ if (e instanceof DpopNonceError) return dpopErrorResponse(env, e);
151
+ return jsonError('invalid_request', errorMessage(e) ?? 'Unknown error');
152
+ }
153
+ }
154
+
155
+ function jsonError(code: string, desc?: string): Response {
156
+ const headers = new Headers({ 'Content-Type': 'application/json' });
157
+ return new Response(JSON.stringify({ error: code, error_description: desc }), { status: 400, headers });
158
+ }