@alteran/astro 0.6.1 → 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.
Files changed (46) hide show
  1. package/README.md +23 -0
  2. package/index.js +8 -0
  3. package/migrations/0009_oauth_session_state.sql +31 -0
  4. package/migrations/meta/0009_snapshot.json +749 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +2 -1
  7. package/src/db/account.ts +134 -1
  8. package/src/db/schema.ts +31 -0
  9. package/src/handlers/root.ts +1 -1
  10. package/src/lib/appview/proxy.ts +11 -8
  11. package/src/lib/auth.ts +34 -3
  12. package/src/lib/jwt.ts +4 -0
  13. package/src/lib/oauth/as-keys.ts +29 -0
  14. package/src/lib/oauth/clients.ts +453 -24
  15. package/src/lib/oauth/consent.ts +180 -0
  16. package/src/lib/oauth/dpop.ts +39 -5
  17. package/src/lib/oauth/resource.ts +93 -21
  18. package/src/lib/oauth/store.ts +64 -7
  19. package/src/lib/refresh-session.ts +16 -0
  20. package/src/lib/session-tokens.ts +33 -5
  21. package/src/lib/token-cleanup.ts +4 -2
  22. package/src/lib/util.ts +0 -1
  23. package/src/pages/.well-known/oauth-authorization-server.ts +16 -3
  24. package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
  25. package/src/pages/oauth/authorize.ts +31 -52
  26. package/src/pages/oauth/consent.ts +163 -66
  27. package/src/pages/oauth/jwks.ts +15 -0
  28. package/src/pages/oauth/par.ts +34 -56
  29. package/src/pages/oauth/revoke.ts +75 -0
  30. package/src/pages/oauth/token.ts +148 -89
  31. package/src/pages/xrpc/[...nsid].ts +7 -6
  32. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
  33. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
  34. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
  35. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
  36. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
  37. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
  38. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
  39. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
  40. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
  41. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
  42. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
  43. package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
  44. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
  45. package/src/worker/runtime.ts +23 -1
  46. package/types/env.d.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import type { Env } from '../env';
2
- import { cleanupExpiredRefreshTokens } from '../db/account';
2
+ import { cleanupExpiredOAuthReplaySecrets, cleanupExpiredRefreshTokens } from '../db/account';
3
3
 
4
4
  /**
5
5
  * Clean up expired tokens from the revocation table
@@ -7,7 +7,9 @@ import { cleanupExpiredRefreshTokens } from '../db/account';
7
7
  */
8
8
  export async function cleanupExpiredTokens(env: Env): Promise<number> {
9
9
  const now = Math.floor(Date.now() / 1000);
10
- return cleanupExpiredRefreshTokens(env, now);
10
+ const refreshTokens = await cleanupExpiredRefreshTokens(env, now);
11
+ const oauthReplayEntries = await cleanupExpiredOAuthReplaySecrets(env, now);
12
+ return refreshTokens + oauthReplayEntries;
11
13
  }
12
14
 
13
15
  /**
package/src/lib/util.ts CHANGED
@@ -31,7 +31,6 @@ export function bearerToken(request: Request): string | null {
31
31
  const auth = request.headers.get('authorization');
32
32
  if (!auth) return null;
33
33
  if (auth.startsWith('Bearer ')) return auth.slice(7);
34
- if (auth.startsWith('DPoP ')) return auth.slice(5);
35
34
  return null;
36
35
  }
37
36
 
@@ -1,5 +1,6 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
+ import { publicPdsOrigin } from '../../lib/oauth/consent';
3
4
 
4
5
  export const prerender = false;
5
6
 
@@ -8,19 +9,31 @@ export async function GET({ locals, request }: APIContext) {
8
9
  return withCache(
9
10
  request,
10
11
  async () => {
11
- const url = new URL(request.url);
12
- const origin = `${url.protocol}//${url.host}`;
12
+ const origin = publicPdsOrigin(env, request);
13
13
  const json = {
14
14
  issuer: origin,
15
15
  pushed_authorization_request_endpoint: `${origin}/oauth/par`,
16
16
  authorization_endpoint: `${origin}/oauth/authorize`,
17
17
  token_endpoint: `${origin}/oauth/token`,
18
- scopes_supported: 'atproto transition:generic',
18
+ jwks_uri: `${origin}/oauth/jwks`,
19
+ revocation_endpoint: `${origin}/oauth/revoke`,
20
+ scopes_supported: ['atproto', 'transition:generic'],
19
21
  response_types_supported: ['code'],
22
+ response_modes_supported: ['query'],
20
23
  grant_types_supported: ['authorization_code', 'refresh_token'],
21
24
  code_challenge_methods_supported: ['S256'],
22
25
  token_endpoint_auth_methods_supported: ['none', 'private_key_jwt'],
26
+ token_endpoint_auth_signing_alg_values_supported: ['ES256'],
23
27
  dpop_signing_alg_values_supported: ['ES256'],
28
+ subject_types_supported: ['public'],
29
+ prompt_values_supported: ['none', 'consent', 'login'],
30
+ require_pushed_authorization_requests: true,
31
+ request_parameter_supported: false,
32
+ request_uri_parameter_supported: true,
33
+ require_request_uri_registration: false,
34
+ authorization_response_iss_parameter_supported: true,
35
+ client_id_metadata_document_supported: true,
36
+ protected_resources: [origin],
24
37
  };
25
38
  return new Response(JSON.stringify(json, null, 2), {
26
39
  headers: { 'Content-Type': 'application/json' },
@@ -1,16 +1,21 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { withCache, CACHE_CONFIGS } from '../../lib/cache';
3
+ import { publicPdsOrigin } from '../../lib/oauth/consent';
3
4
 
4
5
  export const prerender = false;
5
6
 
6
- export async function GET({ request }: APIContext) {
7
+ export async function GET({ locals, request }: APIContext) {
8
+ const { env } = locals.runtime;
7
9
  return withCache(
8
10
  request,
9
11
  async () => {
10
- const url = new URL(request.url);
11
- const origin = `${url.protocol}//${url.host}`;
12
+ const origin = publicPdsOrigin(env, request);
12
13
  const json = {
14
+ resource: origin,
13
15
  authorization_servers: [origin],
16
+ bearer_methods_supported: ['header'],
17
+ scopes_supported: ['atproto', 'transition:generic'],
18
+ resource_documentation: `${origin}/.well-known/oauth-protected-resource`,
14
19
  };
15
20
  return new Response(JSON.stringify(json, null, 2), {
16
21
  headers: { 'Content-Type': 'application/json' },
@@ -19,4 +24,3 @@ export async function GET({ request }: APIContext) {
19
24
  CACHE_CONFIGS.WELL_KNOWN,
20
25
  );
21
26
  }
22
-
@@ -1,78 +1,57 @@
1
1
  import type { APIContext } from 'astro';
2
- import { loadPar, saveCode, deletePar } from '../../lib/oauth/store';
2
+ import { deletePar, loadPar } from '../../lib/oauth/store';
3
+ import { loginHintMatchesSingleUser, publicPdsOrigin, redirectWithOAuthError } from '../../lib/oauth/consent';
3
4
 
4
5
  export const prerender = false;
5
6
 
6
- function parseRequestUri(u: string): string | null {
7
+ function parseRequestUri(v: string | null): string | null {
7
8
  const p = 'urn:ietf:params:oauth:request_uri:';
8
- if (!u || !u.startsWith(p)) return null;
9
- const id = u.slice(p.length);
9
+ if (!v || !v.startsWith(p)) return null;
10
+ const id = v.slice(p.length);
10
11
  return /^[A-Za-z0-9]+$/.test(id) ? id : null;
11
12
  }
12
13
 
13
14
  export async function GET({ locals, request }: APIContext) {
14
15
  const { env } = locals.runtime;
15
16
  const url = new URL(request.url);
16
- const request_uri = url.searchParams.get('request_uri') || '';
17
+ const issuer = publicPdsOrigin(env, request);
17
18
  const client_id = url.searchParams.get('client_id') || '';
18
- const deny = url.searchParams.get('deny') === '1';
19
- const prompt = url.searchParams.get('prompt') || '';
20
-
19
+ const request_uri = url.searchParams.get('request_uri');
20
+ const frontChannelPrompt = (url.searchParams.get('prompt') || '').split(' ').filter(Boolean);
21
21
  const id = parseRequestUri(request_uri);
22
22
  if (!id) {
23
- return new Response('invalid request_uri', { status: 400 });
23
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'invalid request_uri' }), {
24
+ status: 400,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ });
24
27
  }
28
+
25
29
  const par = await loadPar(env, id);
26
30
  if (!par) {
27
- return new Response('request expired or not found', { status: 400 });
31
+ return new Response(JSON.stringify({ error: 'invalid_request_uri' }), {
32
+ status: 400,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ });
28
35
  }
36
+
29
37
  if (client_id && client_id !== par.client_id) {
30
- return new Response('client_id mismatch', { status: 400 });
38
+ await deletePar(env, id);
39
+ return redirectWithOAuthError(par.redirect_uri, 'invalid_request', par.state, issuer, 'client_id mismatch');
31
40
  }
32
41
 
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() } });
42
+ if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
43
+ await deletePar(env, id);
44
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
38
45
  }
39
46
 
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() } });
47
+ const parPrompt = (par.prompt || '').split(' ').filter(Boolean);
48
+ if (frontChannelPrompt.includes('none') || parPrompt.includes('none')) {
49
+ await deletePar(env, id);
50
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
46
51
  }
47
52
 
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
- });
53
+ const consentUrl = new URL('/oauth/consent', issuer);
54
+ consentUrl.searchParams.set('request_uri', request_uri ?? '');
55
+ consentUrl.searchParams.set('client_id', par.client_id);
56
+ return Response.redirect(consentUrl.toString(), 302);
78
57
  }
@@ -1,80 +1,177 @@
1
1
  import type { APIContext } from 'astro';
2
- import { loadPar } from '../../lib/oauth/store';
2
+ import { deletePar, loadPar, saveCode, saveConsent, consumeConsent } from '../../lib/oauth/store';
3
+ import {
4
+ authenticateSingleUserPassword,
5
+ checkConsentPasswordLockout,
6
+ clearConsentPasswordFailures,
7
+ htmlEscape,
8
+ isSameOriginPost,
9
+ loginHintMatchesSingleUser,
10
+ publicPdsOrigin,
11
+ reserveConsentPasswordAttempt,
12
+ redirectWithCode,
13
+ redirectWithOAuthError,
14
+ } from '../../lib/oauth/consent';
3
15
  import { fetchClientMetadata } from '../../lib/oauth/clients';
4
16
 
5
17
  export const prerender = false;
6
18
 
7
- function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'} as any)[c]); }
19
+ function parseRequestUri(v: string | null): string | null {
20
+ const p = 'urn:ietf:params:oauth:request_uri:';
21
+ if (!v || !v.startsWith(p)) return null;
22
+ const id = v.slice(p.length);
23
+ return /^[A-Za-z0-9]+$/.test(id) ? id : null;
24
+ }
8
25
 
9
26
  export async function GET({ locals, request }: APIContext) {
27
+ const { env } = locals.runtime;
10
28
  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 });
29
+ const request_uri = url.searchParams.get('request_uri');
30
+ const id = parseRequestUri(request_uri);
31
+ if (!id) {
32
+ return new Response('Invalid OAuth request', { status: 400 });
33
+ }
34
+ const par = await loadPar(env, id);
35
+ if (!par) {
36
+ return new Response('OAuth request expired', { status: 400 });
37
+ }
38
+ const issuer = publicPdsOrigin(env, request);
39
+ if ((par.prompt || '').split(' ').filter(Boolean).includes('none')) {
40
+ await deletePar(env, id);
41
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
42
+ }
43
+ if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
44
+ await deletePar(env, id);
45
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
46
+ }
19
47
 
20
- let meta: any = null;
48
+ let clientName = par.client_id;
21
49
  try {
22
- meta = await fetchClientMetadata(par.client_id);
50
+ const meta = await fetchClientMetadata(env, par.client_id);
51
+ if (typeof (meta as any).client_name === 'string') clientName = (meta as any).client_name;
23
52
  } catch {
24
- // Client metadata is decorative on this page; the consent form still renders.
53
+ // The PAR step already validated metadata; keep consent rendering available
54
+ // if the client metadata endpoint is temporarily unavailable.
25
55
  }
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' } });
56
+
57
+ const csrf = crypto.randomUUID().replace(/-/g, '');
58
+ await saveConsent(env, id, csrf, Math.floor(Date.now() / 1000) + 300);
59
+
60
+ const body = `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="utf-8" />
64
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
65
+ <title>Authorize ${htmlEscape(clientName)}</title>
66
+ <style>
67
+ body { font-family: system-ui, sans-serif; max-width: 38rem; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }
68
+ label, input, button { display: block; width: 100%; box-sizing: border-box; }
69
+ input { margin: .4rem 0 1rem; padding: .7rem; }
70
+ button { margin-top: .75rem; padding: .75rem; }
71
+ </style>
72
+ </head>
73
+ <body>
74
+ <h1>Authorize ${htmlEscape(clientName)}</h1>
75
+ <p>${htmlEscape(clientName)} is requesting access to ${htmlEscape(par.scope)}.</p>
76
+ <form method="post" action="/oauth/consent">
77
+ <input type="hidden" name="request_uri" value="${htmlEscape(request_uri ?? '')}" />
78
+ <input type="hidden" name="client_id" value="${htmlEscape(par.client_id)}" />
79
+ <input type="hidden" name="csrf" value="${csrf}" />
80
+ <label>Password<input name="password" type="password" autocomplete="current-password" required /></label>
81
+ <button name="decision" value="allow" type="submit">Allow</button>
82
+ <button name="decision" value="deny" type="submit" formnovalidate>Deny</button>
83
+ </form>
84
+ </body>
85
+ </html>`;
86
+
87
+ return new Response(body, {
88
+ headers: {
89
+ 'Content-Type': 'text/html; charset=utf-8',
90
+ 'Cache-Control': 'no-store',
91
+ },
92
+ });
79
93
  }
80
94
 
95
+ export async function POST({ locals, request }: APIContext) {
96
+ const { env } = locals.runtime;
97
+ if (!isSameOriginPost(request)) {
98
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'same-origin POST required' }), {
99
+ status: 403,
100
+ headers: { 'Content-Type': 'application/json' },
101
+ });
102
+ }
103
+
104
+ const form = new URLSearchParams(await request.text());
105
+ const request_uri = form.get('request_uri');
106
+ const id = parseRequestUri(request_uri);
107
+ const csrf = form.get('csrf') || '';
108
+ if (!id || !(await consumeConsent(env, id, csrf))) {
109
+ return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'invalid csrf' }), {
110
+ status: 403,
111
+ headers: { 'Content-Type': 'application/json' },
112
+ });
113
+ }
114
+
115
+ const issuer = publicPdsOrigin(env, request);
116
+ const par = await loadPar(env, id);
117
+ if (!par) {
118
+ return new Response(JSON.stringify({ error: 'invalid_request_uri' }), {
119
+ status: 400,
120
+ headers: { 'Content-Type': 'application/json' },
121
+ });
122
+ }
123
+
124
+ if ((form.get('client_id') || '') !== par.client_id) {
125
+ await deletePar(env, id);
126
+ return redirectWithOAuthError(par.redirect_uri, 'invalid_request', par.state, issuer, 'client_id mismatch');
127
+ }
128
+
129
+ if ((par.prompt || '').split(' ').filter(Boolean).includes('none')) {
130
+ await deletePar(env, id);
131
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer);
132
+ }
133
+
134
+ if (!(await loginHintMatchesSingleUser(env, par.login_hint))) {
135
+ await deletePar(env, id);
136
+ return redirectWithOAuthError(par.redirect_uri, 'login_required', par.state, issuer, 'login_hint does not match this PDS account');
137
+ }
138
+
139
+ const decision = form.get('decision') || '';
140
+ if (decision !== 'allow') {
141
+ await deletePar(env, id);
142
+ return redirectWithOAuthError(par.redirect_uri, 'access_denied', par.state, issuer);
143
+ }
144
+
145
+ const lockout = await checkConsentPasswordLockout(env, request);
146
+ if (lockout) return lockout;
147
+ const reservedAttempt = await reserveConsentPasswordAttempt(env, request);
148
+ if (reservedAttempt) {
149
+ await deletePar(env, id);
150
+ return reservedAttempt;
151
+ }
152
+
153
+ const account = await authenticateSingleUserPassword(env, form.get('password') || '');
154
+ if (!account) {
155
+ await deletePar(env, id);
156
+ return redirectWithOAuthError(par.redirect_uri, 'access_denied', par.state, issuer, 'invalid credentials');
157
+ }
158
+ await clearConsentPasswordFailures(env, request);
159
+
160
+ const code = crypto.randomUUID().replace(/-/g, '');
161
+ const now = Math.floor(Date.now() / 1000);
162
+ await saveCode(env, code, {
163
+ code,
164
+ client_id: par.client_id,
165
+ redirect_uri: par.redirect_uri,
166
+ code_challenge: par.code_challenge,
167
+ scope: par.scope,
168
+ dpopJkt: par.dpopJkt,
169
+ clientAuthMethod: par.clientAuthMethod,
170
+ clientAuthKeyId: par.clientAuthKeyId ?? null,
171
+ did: account.did,
172
+ createdAt: now,
173
+ expiresAt: now + 300,
174
+ });
175
+ await deletePar(env, id);
176
+ return redirectWithCode(par.redirect_uri, code, par.state, issuer);
177
+ }
@@ -0,0 +1,15 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getAuthorizationServerPublicJwk } from '../../lib/oauth/as-keys';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET({ locals }: APIContext) {
7
+ const { env } = locals.runtime;
8
+ const jwk = await getAuthorizationServerPublicJwk(env);
9
+ return new Response(JSON.stringify({ keys: [jwk] }, null, 2), {
10
+ headers: {
11
+ 'Content-Type': 'application/json',
12
+ 'Cache-Control': 'public, max-age=300',
13
+ },
14
+ });
15
+ }
@@ -1,9 +1,15 @@
1
1
  import type { APIContext } from 'astro';
2
2
  import { errorMessage } from '../../lib/errors';
3
- import { getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
3
+ import { consumeDpopVerificationJti, getAuthzNonce, setDpopNonceHeader, verifyDpop, dpopErrorResponse } from '../../lib/oauth/dpop';
4
4
  import { DpopNonceError } from '../../lib/oauth/dpop-errors';
5
+ import { publicPdsOrigin } from '../../lib/oauth/consent';
5
6
  import { savePar } from '../../lib/oauth/store';
6
- import { fetchClientMetadata, isHttpsUrl, verifyClientAssertion } from '../../lib/oauth/clients';
7
+ import {
8
+ fetchClientMetadata,
9
+ isSafeFetchUrl,
10
+ validateParRequest,
11
+ verifyClientAuthentication,
12
+ } from '../../lib/oauth/clients';
7
13
 
8
14
  export const prerender = false;
9
15
 
@@ -12,83 +18,51 @@ export async function POST({ locals, request }: APIContext) {
12
18
 
13
19
  // Enforce DPoP with nonce; if missing or stale, return use_dpop_nonce
14
20
  try {
15
- const ver = await verifyDpop(env, request);
21
+ const ver = await verifyDpop(env, request, { consumeJti: false });
16
22
 
17
23
  // Parse form-encoded body
18
24
  const bodyText = await request.text();
19
25
  const form = new URLSearchParams(bodyText);
20
26
  const client_id = form.get('client_id') || '';
21
27
  const response_type = form.get('response_type') || '';
28
+ const grant_type = form.get('grant_type') || undefined;
22
29
  const redirect_uri = form.get('redirect_uri') || '';
23
30
  const scope = form.get('scope') || '';
24
31
  const state = form.get('state') || '';
25
32
  const code_challenge = form.get('code_challenge') || '';
26
33
  const code_challenge_method = form.get('code_challenge_method') || '';
27
34
  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') || '';
35
+ const prompt = form.get('prompt') || undefined;
30
36
 
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' } });
37
+ if (!client_id || !isSafeFetchUrl(client_id)) {
38
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be a safe https metadata URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
42
39
  }
43
40
  if (!state) {
44
41
  return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
45
42
  }
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
43
 
50
44
  // Fetch and validate client metadata
51
- let clientMeta: any = null;
45
+ let clientMeta;
52
46
  try {
53
- clientMeta = await fetchClientMetadata(client_id);
47
+ clientMeta = await fetchClientMetadata(env, client_id);
48
+ validateParRequest(clientMeta, {
49
+ response_type,
50
+ grant_type,
51
+ redirect_uri,
52
+ scope,
53
+ code_challenge,
54
+ code_challenge_method,
55
+ });
54
56
  } catch (e) {
55
57
  return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
56
58
  }
57
59
 
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
- }
60
+ const issuerOrigin = publicPdsOrigin(env, request);
61
+ let clientAuth;
62
+ try {
63
+ clientAuth = await verifyClientAuthentication(env, client_id, issuerOrigin, clientMeta, form);
64
+ } catch (e) {
65
+ return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client authentication failed' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
92
66
  }
93
67
 
94
68
  const id = crypto.randomUUID().replace(/-/g, '');
@@ -101,16 +75,20 @@ export async function POST({ locals, request }: APIContext) {
101
75
  scope,
102
76
  state,
103
77
  login_hint,
78
+ prompt,
104
79
  dpopJkt: ver.jkt,
80
+ clientAuthMethod: clientAuth.method,
81
+ clientAuthKeyId: clientAuth.keyId,
105
82
  createdAt: now,
106
83
  expiresAt: now + 300, // 5 minutes
107
84
  };
85
+ await consumeDpopVerificationJti(env, ver);
108
86
  await savePar(env, id, rec);
109
87
 
110
88
  const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
111
89
  const headers = new Headers({ 'Content-Type': 'application/json' });
112
90
  setDpopNonceHeader(headers, await getAuthzNonce(env));
113
- return new Response(JSON.stringify({ request_uri }), { status: 201, headers });
91
+ return new Response(JSON.stringify({ request_uri, expires_in: 300 }), { status: 201, headers });
114
92
  } catch (e) {
115
93
  if (e instanceof DpopNonceError) {
116
94
  return dpopErrorResponse(env, e);