@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
@@ -0,0 +1,180 @@
1
+ import type { Env } from '../../env';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { eq } from 'drizzle-orm';
4
+ import { createAccount, getAccountByIdentifier } from '../../db/account';
5
+ import { login_attempts } from '../../db/schema';
6
+ import { getRuntimeString } from '../secrets';
7
+ import { hashPassword, verifyPassword } from '../password';
8
+
9
+ const MAX_LOGIN_ATTEMPTS = 5;
10
+ const LOCKOUT_DURATION_SEC = 15 * 60;
11
+
12
+ export function redirectWithOAuthError(
13
+ redirectUri: string,
14
+ error: string,
15
+ state: string | undefined,
16
+ issuer: string,
17
+ description?: string,
18
+ ): Response {
19
+ const out = new URL(redirectUri);
20
+ out.searchParams.set('error', error);
21
+ if (description) out.searchParams.set('error_description', description);
22
+ if (state) out.searchParams.set('state', state);
23
+ out.searchParams.set('iss', issuer);
24
+ return Response.redirect(out.toString(), 302);
25
+ }
26
+
27
+ export function redirectWithCode(redirectUri: string, code: string, state: string, issuer: string): Response {
28
+ const out = new URL(redirectUri);
29
+ out.searchParams.set('code', code);
30
+ out.searchParams.set('state', state);
31
+ out.searchParams.set('iss', issuer);
32
+ return Response.redirect(out.toString(), 302);
33
+ }
34
+
35
+ export function requestOrigin(request: Request): string {
36
+ const url = new URL(request.url);
37
+ return `${url.protocol}//${url.host}`;
38
+ }
39
+
40
+ export function publicPdsOrigin(env: Env, request: Request): string {
41
+ const hostname = env.PDS_HOSTNAME?.trim();
42
+ if (!hostname) return requestOrigin(request);
43
+
44
+ try {
45
+ const configured = hostname.includes('://') ? new URL(hostname) : new URL(`https://${hostname}`);
46
+ configured.pathname = '';
47
+ configured.search = '';
48
+ configured.hash = '';
49
+ return configured.origin;
50
+ } catch {
51
+ return requestOrigin(request);
52
+ }
53
+ }
54
+
55
+ export function isSameOriginPost(request: Request): boolean {
56
+ const origin = requestOrigin(request);
57
+ const secFetchSite = request.headers.get('sec-fetch-site');
58
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
59
+ return false;
60
+ }
61
+
62
+ const originHeader = request.headers.get('origin');
63
+ if (originHeader) return originHeader === origin;
64
+
65
+ const referer = request.headers.get('referer');
66
+ if (!referer) return false;
67
+ try {
68
+ const ref = new URL(referer);
69
+ return `${ref.protocol}//${ref.host}` === origin;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ export async function authenticateSingleUserPassword(env: Env, password: string): Promise<{ did: string; handle: string } | null> {
76
+ if (!password) return null;
77
+ const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
78
+ const handle = await getRuntimeString(env, 'PDS_HANDLE', 'user.example');
79
+ if (!did || !handle) return null;
80
+
81
+ let account = await getAccountByIdentifier(env, did);
82
+ if (!account) account = await getAccountByIdentifier(env, handle);
83
+ if (!account) {
84
+ const fallbackPassword = await getRuntimeString(env, 'USER_PASSWORD', '');
85
+ if (!fallbackPassword) return null;
86
+ await createAccount(env, {
87
+ did,
88
+ handle,
89
+ passwordScrypt: await hashPassword(fallbackPassword),
90
+ });
91
+ account = await getAccountByIdentifier(env, did);
92
+ }
93
+ if (!account || account.did !== did) return null;
94
+ if (!(await verifyPassword(password, account.passwordScrypt))) return null;
95
+ return { did: account.did, handle: account.handle };
96
+ }
97
+
98
+ function consentClientIp(request: Request): string {
99
+ return request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
100
+ }
101
+
102
+ export async function checkConsentPasswordLockout(env: Env, request: Request): Promise<Response | null> {
103
+ const db = drizzle(env.ALTERAN_DB);
104
+ const now = Math.floor(Date.now() / 1000);
105
+ const ip = consentClientIp(request);
106
+ const attempt = await db.select().from(login_attempts).where(eq(login_attempts.ip, ip)).get();
107
+ if (attempt?.locked_until && attempt.locked_until > now) {
108
+ const remainingSeconds = attempt.locked_until - now;
109
+ return new Response(
110
+ JSON.stringify({
111
+ error: 'RateLimitExceeded',
112
+ message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`,
113
+ }),
114
+ { status: 429, headers: { 'Content-Type': 'application/json' } },
115
+ );
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function lockoutResponse(remainingSeconds: number): Response {
121
+ return new Response(
122
+ JSON.stringify({
123
+ error: 'RateLimitExceeded',
124
+ message: `Account locked due to too many failed attempts. Try again in ${Math.ceil(remainingSeconds / 60)} minutes.`,
125
+ }),
126
+ { status: 429, headers: { 'Content-Type': 'application/json' } },
127
+ );
128
+ }
129
+
130
+ export async function reserveConsentPasswordAttempt(env: Env, request: Request): Promise<Response | null> {
131
+ const now = Math.floor(Date.now() / 1000);
132
+ const ip = consentClientIp(request);
133
+ const row = await env.ALTERAN_DB.prepare(`
134
+ INSERT INTO login_attempts (ip, attempts, locked_until, last_attempt)
135
+ VALUES (?, 1, NULL, ?)
136
+ ON CONFLICT(ip) DO UPDATE SET
137
+ attempts = CASE
138
+ WHEN locked_until IS NOT NULL AND locked_until > ? THEN attempts
139
+ ELSE attempts + 1
140
+ END,
141
+ locked_until = CASE
142
+ WHEN locked_until IS NOT NULL AND locked_until > ? THEN locked_until
143
+ WHEN attempts + 1 >= ? THEN ?
144
+ ELSE NULL
145
+ END,
146
+ last_attempt = ?
147
+ RETURNING attempts, locked_until
148
+ `).bind(ip, now, now, now, MAX_LOGIN_ATTEMPTS, now + LOCKOUT_DURATION_SEC, now)
149
+ .first<{ attempts: number; locked_until: number | null }>();
150
+
151
+ if (row?.locked_until && row.locked_until > now) {
152
+ return lockoutResponse(row.locked_until - now);
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export async function recordConsentPasswordFailure(env: Env, request: Request): Promise<void> {
158
+ await reserveConsentPasswordAttempt(env, request);
159
+ }
160
+
161
+ export async function clearConsentPasswordFailures(env: Env, request: Request): Promise<void> {
162
+ const db = drizzle(env.ALTERAN_DB);
163
+ await db.delete(login_attempts).where(eq(login_attempts.ip, consentClientIp(request))).run();
164
+ }
165
+
166
+ export async function loginHintMatchesSingleUser(env: Env, loginHint: string | undefined): Promise<boolean> {
167
+ if (!loginHint) return true;
168
+ const did = await getRuntimeString(env, 'PDS_DID', 'did:example:single-user');
169
+ const handle = await getRuntimeString(env, 'PDS_HANDLE', 'user.example');
170
+ return loginHint === did || loginHint === handle;
171
+ }
172
+
173
+ export function htmlEscape(value: string): string {
174
+ return value
175
+ .replace(/&/g, '&amp;')
176
+ .replace(/</g, '&lt;')
177
+ .replace(/>/g, '&gt;')
178
+ .replace(/"/g, '&quot;')
179
+ .replace(/'/g, '&#39;');
180
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Env } from '../../env';
2
2
  import { errorMessage } from '../errors';
3
- import { getOrCreateSecret, setSecret, getSecret } from '../../db/account';
3
+ import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOrCreateSecret, setSecret, getSecret } from '../../db/account';
4
4
  import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
5
5
  import { DpopNonceError } from './dpop-errors';
6
6
 
@@ -15,7 +15,7 @@ export type DpopVerification = {
15
15
  payload: Record<string, unknown>;
16
16
  };
17
17
 
18
- function b64url(bytes: Uint8Array | ArrayBuffer): string {
18
+ export function b64url(bytes: Uint8Array | ArrayBuffer): string {
19
19
  const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
20
20
  let s = '';
21
21
  for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
@@ -48,7 +48,7 @@ export function setDpopNonceHeader(headers: Headers, nonce: string) {
48
48
  }
49
49
 
50
50
  // Compute RFC7638 JWK thumbprint for P-256 JWK
51
- async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
51
+ export async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
52
52
  // Per RFC7638, canonical JSON with these members in lexicographic order
53
53
  const obj: Record<string, string> = {
54
54
  crv: String(jwk.crv ?? ''),
@@ -61,6 +61,29 @@ async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
61
61
  return b64url(digest);
62
62
  }
63
63
 
64
+ export async function consumeDpopJti(env: Env, kind: string, jti: unknown, iat: number): Promise<void> {
65
+ if (typeof jti !== 'string' || jti.length < 8) {
66
+ throw new DpopNonceError('DPoP jti required', await getAuthzNonce(env));
67
+ }
68
+
69
+ const key = `oauth:dpop:jti:${kind}:${jti}`;
70
+ const now = Math.floor(Date.now() / 1000);
71
+ await cleanupExpiredOAuthReplaySecrets(env, now);
72
+ const inserted = await createSecretOnce(env, key, JSON.stringify({ iat, exp: now + 300 }));
73
+ if (!inserted) {
74
+ throw new DpopNonceError('DPoP proof replayed', await getAuthzNonce(env));
75
+ }
76
+ }
77
+
78
+ export async function consumeDpopVerificationJti(
79
+ env: Env,
80
+ verification: DpopVerification,
81
+ kind = 'authz',
82
+ ): Promise<void> {
83
+ const iat = verification.payload.iat;
84
+ await consumeDpopJti(env, kind, verification.payload.jti, typeof iat === 'number' ? iat : 0);
85
+ }
86
+
64
87
  function urlWithoutHash(u: string): string {
65
88
  try {
66
89
  const url = new URL(u);
@@ -73,7 +96,11 @@ function urlWithoutHash(u: string): string {
73
96
 
74
97
  // removed local DER conversion; jose handles verification
75
98
 
76
- export async function verifyDpop(env: Env, request: Request, opts?: { requireNonce?: boolean }): Promise<DpopVerification> {
99
+ export async function verifyDpop(
100
+ env: Env,
101
+ request: Request,
102
+ opts?: { requireNonce?: boolean; consumeJti?: boolean },
103
+ ): Promise<DpopVerification> {
77
104
  const dpop = request.headers.get('DPoP');
78
105
  const nonce = await getAuthzNonce(env);
79
106
  if (!dpop) {
@@ -104,6 +131,9 @@ export async function verifyDpop(env: Env, request: Request, opts?: { requireNon
104
131
  if (typeof payload.iat !== 'number' || now - payload.iat > 300) {
105
132
  throw new DpopNonceError('DPoP iat too old', nonce);
106
133
  }
134
+ if (payload.iat - now > 30) {
135
+ throw new DpopNonceError('DPoP iat is in the future', nonce);
136
+ }
107
137
 
108
138
  if (opts?.requireNonce !== false) {
109
139
  if (!payload.nonce || payload.nonce !== nonce) {
@@ -112,7 +142,11 @@ export async function verifyDpop(env: Env, request: Request, opts?: { requireNon
112
142
  }
113
143
 
114
144
  const jkt = await jwkThumbprint(header.jwk as JsonWebKey);
115
- return { jkt, jwk: header.jwk as JsonWebKey, payload };
145
+ const verification = { jkt, jwk: header.jwk as JsonWebKey, payload };
146
+ if (opts?.consumeJti !== false) {
147
+ await consumeDpopVerificationJti(env, verification);
148
+ }
149
+ return verification;
116
150
  }
117
151
 
118
152
  export function dpopErrorResponse(_env: Env, error: DpopNonceError): Response {
@@ -2,6 +2,8 @@ import type { Env } from '../../env';
2
2
  import { errorCode, errorMessage } from '../errors';
3
3
  import { verifyAccessToken } from '../session-tokens';
4
4
  import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
5
+ import { cleanupExpiredOAuthReplaySecrets, createSecretOnce, getOAuthSession, getSecret, setSecret } from '../../db/account';
6
+ import { jwkThumbprint } from './dpop';
5
7
 
6
8
  const NONCE_PDS_KEY = 'oauth_dpop_nonce_pds';
7
9
 
@@ -31,7 +33,6 @@ function urlWithoutHash(u: string): string {
31
33
  // removed local b64urlToBytes and DER helpers; jose handles verification
32
34
 
33
35
  async function getNonce(env: Env): Promise<string> {
34
- const { getSecret, setSecret } = await import('../../db/account');
35
36
  const now = Math.floor(Date.now() / 1000);
36
37
  const raw = await getSecret(env, NONCE_PDS_KEY);
37
38
  if (raw) {
@@ -47,7 +48,27 @@ async function getNonce(env: Env): Promise<string> {
47
48
  return v;
48
49
  }
49
50
 
50
- export async function verifyResourceRequest(env: Env, request: Request): Promise<{ did: string; token: string } | null> {
51
+ async function consumeResourceDpopJti(env: Env, jti: unknown, iat: number, nonce: string): Promise<void> {
52
+ if (typeof jti !== 'string' || jti.length < 8) {
53
+ throw new ResourceAuthError('use_dpop_nonce', { nonce, message: 'DPoP jti required' });
54
+ }
55
+ const now = Math.floor(Date.now() / 1000);
56
+ const key = `oauth:dpop:jti:resource:${jti}`;
57
+ await cleanupExpiredOAuthReplaySecrets(env, now);
58
+ const inserted = await createSecretOnce(env, key, JSON.stringify({ iat, exp: now + 300 }));
59
+ if (!inserted) {
60
+ throw new ResourceAuthError('invalid_token', { message: 'DPoP proof replayed' });
61
+ }
62
+ }
63
+
64
+ export type ResourceAuthContext = {
65
+ did: string;
66
+ token: string;
67
+ scope?: string;
68
+ authType: 'bearer' | 'oauth-dpop';
69
+ };
70
+
71
+ export async function verifyResourceRequest(env: Env, request: Request): Promise<ResourceAuthContext | null> {
51
72
  const auth = request.headers.get('authorization');
52
73
  if (!auth) return null;
53
74
 
@@ -64,8 +85,13 @@ export async function verifyResourceRequest(env: Env, request: Request): Promise
64
85
  }
65
86
 
66
87
  if (scheme === 'bearer') {
67
- const payload = await verifyAccessTokenOrThrow(env, token);
68
- return { did: payload.sub as string, token };
88
+ const payload = await verifyAccessTokenOrThrow(env, token, { allowOAuth: false });
89
+ return {
90
+ did: payload.sub as string,
91
+ token,
92
+ scope: typeof payload.scope === 'string' ? payload.scope : undefined,
93
+ authType: 'bearer',
94
+ };
69
95
  }
70
96
 
71
97
  return null;
@@ -89,6 +115,7 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
89
115
  if (payload.htm !== method || payload.htu !== url) throw new ResourceAuthError('use_dpop_nonce', { nonce });
90
116
  const now = Math.floor(Date.now()/1000);
91
117
  if (typeof payload.iat !== 'number' || now - payload.iat > 300) throw new ResourceAuthError('use_dpop_nonce', { nonce });
118
+ if (payload.iat - now > 30) throw new ResourceAuthError('use_dpop_nonce', { nonce });
92
119
  if (!payload.nonce || payload.nonce !== nonce) throw new ResourceAuthError('use_dpop_nonce', { nonce });
93
120
  // Verify ath binding
94
121
  const enc = new TextEncoder();
@@ -96,16 +123,29 @@ async function verifyDpopAccess(env: Env, request: Request, accessToken: string)
96
123
  const accessBuf = (() => { const b = new ArrayBuffer(accessBytes.byteLength); new Uint8Array(b).set(accessBytes); return b; })();
97
124
  const expectedAth = await crypto.subtle.digest('SHA-256', accessBuf);
98
125
  const expectedAthB64 = b64url(expectedAth);
99
- if (payload.ath !== expectedAthB64) throw new ResourceAuthError('use_dpop_nonce', { nonce });
126
+ if (payload.ath !== expectedAthB64) throw new ResourceAuthError('invalid_token', { message: 'DPoP ath mismatch' });
100
127
  // Verify signature with JOSE
101
- const key = await importJWK(header.jwk as JoseJWK, 'ES256');
102
- // already verified above, nothing else to do
128
+ await importJWK(header.jwk as JoseJWK, 'ES256');
103
129
 
104
- const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken);
105
- return { did: tokenPayload.sub as string, token: accessToken };
130
+ const tokenPayload = await verifyAccessTokenOrThrow(env, accessToken, { allowOAuth: true });
131
+ const tokenJkt = (tokenPayload.cnf as any)?.jkt;
132
+ if (typeof tokenJkt !== 'string') {
133
+ throw new ResourceAuthError('invalid_token', { message: 'DPoP access token missing cnf.jkt' });
134
+ }
135
+ const proofJkt = await jwkThumbprint(header.jwk as JsonWebKey);
136
+ if (proofJkt !== tokenJkt) {
137
+ throw new ResourceAuthError('invalid_token', { message: 'DPoP key mismatch' });
138
+ }
139
+ await consumeResourceDpopJti(env, payload.jti, payload.iat, nonce);
140
+ return {
141
+ did: tokenPayload.sub as string,
142
+ token: accessToken,
143
+ scope: typeof tokenPayload.scope === 'string' ? tokenPayload.scope : undefined,
144
+ authType: 'oauth-dpop' as const,
145
+ };
106
146
  }
107
147
 
108
- async function verifyAccessTokenOrThrow(env: Env, token: string) {
148
+ async function verifyAccessTokenOrThrow(env: Env, token: string, opts: { allowOAuth?: boolean } = {}) {
109
149
  let payloadJwt: Awaited<ReturnType<typeof verifyAccessToken>>;
110
150
  try {
111
151
  payloadJwt = await verifyAccessToken(env, token);
@@ -122,6 +162,30 @@ async function verifyAccessTokenOrThrow(env: Env, token: string) {
122
162
  if (!payloadJwt || !payloadJwt.sub) {
123
163
  throw new ResourceAuthError('invalid_token');
124
164
  }
165
+ const isOAuthToken = !!(payloadJwt.cnf as any)?.jkt;
166
+ if (isOAuthToken && !opts.allowOAuth) {
167
+ throw new ResourceAuthError('invalid_token');
168
+ }
169
+ if (isOAuthToken) {
170
+ const sessionId = payloadJwt.oauth_session;
171
+ const accessJti = payloadJwt.jti;
172
+ const clientId = payloadJwt.client_id;
173
+ if (typeof sessionId !== 'string' || typeof accessJti !== 'string' || typeof clientId !== 'string') {
174
+ throw new ResourceAuthError('invalid_token');
175
+ }
176
+ const session = await getOAuthSession(env, sessionId);
177
+ const now = Math.floor(Date.now() / 1000);
178
+ if (
179
+ !session ||
180
+ session.revokedAt ||
181
+ session.expiresAt <= now ||
182
+ session.accessJti !== accessJti ||
183
+ session.clientId !== clientId ||
184
+ session.did !== payloadJwt.sub
185
+ ) {
186
+ throw new ResourceAuthError('invalid_token');
187
+ }
188
+ }
125
189
  return payloadJwt;
126
190
  }
127
191
 
@@ -151,27 +215,35 @@ export async function verifyResourceRequestHybrid(
151
215
  env: Env,
152
216
  request: Request,
153
217
  deps: VerifyResourceHybridDeps = defaultVerifyHybridDeps,
154
- ): Promise<{ did: string; token: string } | null> {
218
+ ): Promise<ResourceAuthContext | null> {
155
219
  const auth = request.headers.get('authorization');
156
220
  if (!auth) return null;
157
221
 
158
- // Try DPoP authentication first (new OAuth flow)
159
- if (auth.startsWith('DPoP ')) {
222
+ const match = auth.match(/^(\S+)\s+(.+)$/);
223
+ if (!match) return null;
224
+
225
+ const [, schemeRaw, tokenRaw] = match;
226
+ const scheme = schemeRaw?.toLowerCase();
227
+ const token = tokenRaw?.trim();
228
+ if (!scheme || !token) return null;
229
+
230
+ if (scheme === 'dpop') {
160
231
  try {
161
232
  const result = await verifyResourceRequest(env, request);
162
233
  if (result) return result;
163
234
  } catch (e) {
164
- // If it's a nonce error, propagate it
165
- if (errorCode(e) === 'use_dpop_nonce') throw e;
166
- // Otherwise fall through to Bearer
235
+ throw e;
167
236
  }
168
237
  }
169
238
 
170
- // Fall back to Bearer token authentication (legacy XRPC flow)
171
- if (auth.startsWith('Bearer ')) {
172
- const token = auth.slice(7).trim();
173
- const payloadJwt = await deps.verifyAccessToken(env, token);
174
- return { did: payloadJwt.sub as string, token };
239
+ if (scheme === 'bearer') {
240
+ const payloadJwt = await deps.verifyAccessToken(env, token, { allowOAuth: false });
241
+ return {
242
+ did: payloadJwt.sub as string,
243
+ token,
244
+ scope: typeof payloadJwt.scope === 'string' ? payloadJwt.scope : undefined,
245
+ authType: 'bearer',
246
+ };
175
247
  }
176
248
 
177
249
  return null;
@@ -3,6 +3,7 @@ import { getSecret, setSecret } from '../../db/account';
3
3
 
4
4
  const PAR_PREFIX = 'oauth:par:';
5
5
  const CODE_PREFIX = 'oauth:code:';
6
+ const CONSENT_PREFIX = 'oauth:consent:';
6
7
 
7
8
  export interface ParRecord {
8
9
  client_id: string;
@@ -12,7 +13,10 @@ export interface ParRecord {
12
13
  scope: string;
13
14
  state: string;
14
15
  login_hint?: string;
16
+ prompt?: string;
15
17
  dpopJkt: string;
18
+ clientAuthMethod: 'none' | 'private_key_jwt';
19
+ clientAuthKeyId?: string | null;
16
20
  createdAt: number;
17
21
  expiresAt: number;
18
22
  }
@@ -24,12 +28,21 @@ export interface CodeRecord {
24
28
  code_challenge: string;
25
29
  scope: string;
26
30
  dpopJkt: string;
31
+ clientAuthMethod: 'none' | 'private_key_jwt';
32
+ clientAuthKeyId?: string | null;
27
33
  did: string;
28
34
  createdAt: number;
29
35
  expiresAt: number;
30
36
  used?: boolean;
31
37
  }
32
38
 
39
+ export interface ConsentRecord {
40
+ id: string;
41
+ csrf: string;
42
+ createdAt: number;
43
+ expiresAt: number;
44
+ }
45
+
33
46
  export async function savePar(env: Env, id: string, rec: ParRecord): Promise<void> {
34
47
  await setSecret(env, PAR_PREFIX + id, JSON.stringify(rec));
35
48
  }
@@ -39,7 +52,20 @@ export async function loadPar(env: Env, id: string): Promise<ParRecord | null> {
39
52
  if (!raw) return null;
40
53
  try {
41
54
  const rec = JSON.parse(raw) as ParRecord;
42
- if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return null;
55
+ if (
56
+ typeof rec.client_id !== 'string' ||
57
+ typeof rec.redirect_uri !== 'string' ||
58
+ typeof rec.code_challenge !== 'string' ||
59
+ typeof rec.scope !== 'string' ||
60
+ typeof rec.state !== 'string' ||
61
+ typeof rec.dpopJkt !== 'string'
62
+ ) {
63
+ return null;
64
+ }
65
+ if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) {
66
+ await deletePar(env, id);
67
+ return null;
68
+ }
43
69
  return rec;
44
70
  } catch {
45
71
  return null;
@@ -51,6 +77,28 @@ export async function deletePar(env: Env, id: string): Promise<void> {
51
77
  await setSecret(env, PAR_PREFIX + id, JSON.stringify({}));
52
78
  }
53
79
 
80
+ export async function saveConsent(env: Env, id: string, csrf: string, expiresAt: number): Promise<void> {
81
+ const now = Math.floor(Date.now() / 1000);
82
+ await setSecret(env, CONSENT_PREFIX + id, JSON.stringify({ id, csrf, createdAt: now, expiresAt }));
83
+ }
84
+
85
+ export async function consumeConsent(env: Env, id: string, csrf: string): Promise<boolean> {
86
+ const key = CONSENT_PREFIX + id;
87
+ const raw = await getSecret(env, key);
88
+ if (!raw) return false;
89
+ try {
90
+ const rec = JSON.parse(raw) as ConsentRecord;
91
+ if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return false;
92
+ if (rec.id !== id || rec.csrf !== csrf) return false;
93
+ const response = await env.ALTERAN_DB.prepare(
94
+ 'UPDATE secret SET value = ?, updated_at = ? WHERE key = ? AND value = ?'
95
+ ).bind(JSON.stringify({}), Math.floor(Date.now()), key, raw).run();
96
+ return (response.meta.changes ?? 0) === 1;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
54
102
  export async function saveCode(env: Env, code: string, rec: CodeRecord): Promise<void> {
55
103
  await setSecret(env, CODE_PREFIX + code, JSON.stringify(rec));
56
104
  }
@@ -68,10 +116,19 @@ export async function loadCode(env: Env, code: string): Promise<CodeRecord | nul
68
116
  }
69
117
 
70
118
  export async function consumeCode(env: Env, code: string): Promise<CodeRecord | null> {
71
- const rec = await loadCode(env, code);
72
- if (!rec) return null;
73
- if (rec.used) return null;
74
- rec.used = true;
75
- await setSecret(env, CODE_PREFIX + code, JSON.stringify(rec));
76
- return rec;
119
+ const key = CODE_PREFIX + code;
120
+ const raw = await getSecret(env, key);
121
+ if (!raw) return null;
122
+ try {
123
+ const rec = JSON.parse(raw) as CodeRecord;
124
+ if (rec.expiresAt && rec.expiresAt < Math.floor(Date.now() / 1000)) return null;
125
+ if (rec.used) return null;
126
+ const used = JSON.stringify({ ...rec, used: true });
127
+ const response = await env.ALTERAN_DB.prepare(
128
+ 'UPDATE secret SET value = ?, updated_at = ? WHERE key = ? AND value = ?'
129
+ ).bind(used, Math.floor(Date.now()), key, raw).run();
130
+ return (response.meta.changes ?? 0) === 1 ? rec : null;
131
+ } catch {
132
+ return null;
133
+ }
77
134
  }
@@ -99,6 +99,22 @@ export async function attemptRefresh({ env, token, nowSec }: AttemptInput): Prom
99
99
  return failure('InvalidToken', 'Refresh token has been revoked');
100
100
  }
101
101
 
102
+ if (stored.revokedAt) {
103
+ return failure('InvalidToken', 'Refresh token has been revoked');
104
+ }
105
+
106
+ if (stored.tokenKind !== 'legacy') {
107
+ return failure('InvalidToken', 'OAuth refresh token must use /oauth/token');
108
+ }
109
+
110
+ if (
111
+ typeof verification.payload.client_id === 'string' ||
112
+ typeof verification.payload.oauth_session === 'string' ||
113
+ !!(verification.payload.cnf as { jkt?: unknown } | undefined)?.jkt
114
+ ) {
115
+ return failure('InvalidToken', 'OAuth refresh token must use /oauth/token');
116
+ }
117
+
102
118
  if (stored.expiresAt <= nowSec) {
103
119
  return failure('ExpiredToken', 'Refresh token expired');
104
120
  }
@@ -32,19 +32,34 @@ async function getServiceDid(env: Env): Promise<string> {
32
32
  return did;
33
33
  }
34
34
 
35
- export async function issueSessionTokens(env: Env, did: string, opts: { jti?: string } = {}) {
35
+ export type IssueSessionTokenOptions = {
36
+ jti?: string;
37
+ accessJti?: string;
38
+ scope?: string;
39
+ clientId?: string;
40
+ dpopJkt?: string;
41
+ oauthSessionId?: string;
42
+ };
43
+
44
+ export async function issueSessionTokens(env: Env, did: string, opts: IssueSessionTokenOptions = {}) {
36
45
  const jwtKey = await getJwtKey(env);
37
46
  const serviceDid = await getServiceDid(env);
38
47
  const now = Math.floor(Date.now() / 1000);
39
48
 
40
49
  const accessExp = now + ACCESS_TTL_SECONDS;
41
50
  const accessPayload: TokenPayload = {
42
- scope: 'access',
51
+ scope: opts.dpopJkt ? (opts.scope ?? 'atproto') : 'access',
43
52
  aud: serviceDid,
44
53
  sub: did,
45
54
  iat: now,
46
55
  exp: accessExp,
47
56
  };
57
+ if (opts.dpopJkt) {
58
+ accessPayload.cnf = { jkt: opts.dpopJkt };
59
+ accessPayload.jti = opts.accessJti ?? generateTokenId();
60
+ if (opts.clientId) accessPayload.client_id = opts.clientId;
61
+ if (opts.oauthSessionId) accessPayload.oauth_session = opts.oauthSessionId;
62
+ }
48
63
  const accessJwt = await signJwt(jwtKey, 'at+jwt', accessPayload);
49
64
 
50
65
  const jti = opts.jti ?? generateTokenId();
@@ -57,20 +72,26 @@ export async function issueSessionTokens(env: Env, did: string, opts: { jti?: st
57
72
  exp: refreshExp,
58
73
  jti,
59
74
  };
75
+ if (opts.dpopJkt) {
76
+ refreshPayload.cnf = { jkt: opts.dpopJkt };
77
+ if (opts.clientId) refreshPayload.client_id = opts.clientId;
78
+ if (opts.oauthSessionId) refreshPayload.oauth_session = opts.oauthSessionId;
79
+ }
60
80
  const refreshJwt = await signJwt(jwtKey, 'refresh+jwt', refreshPayload);
61
81
 
62
82
  return {
63
83
  accessJwt,
64
84
  refreshJwt,
85
+ accessPayload,
65
86
  refreshPayload,
66
87
  refreshExpiry: refreshPayload.exp,
67
88
  } as const;
68
89
  }
69
90
 
70
- export async function verifyRefreshToken(env: Env, token: string) {
91
+ export async function verifyRefreshToken(env: Env, token: string, opts: { ignoreExpiration?: boolean } = {}) {
71
92
  const key = await getJwtKey(env);
72
93
  const serviceDid = await getServiceDid(env);
73
- const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid);
94
+ const { header, payload } = await decodeAndVerifyJwt(key, token, 'refresh+jwt', serviceDid, opts);
74
95
  if (header.typ !== 'refresh+jwt') {
75
96
  throw new InvalidToken('Invalid token type');
76
97
  }
@@ -131,10 +152,17 @@ async function signJwt(key: Uint8Array, typ: TokenHeader['typ'], payload: TokenP
131
152
  return await signer.sign(key);
132
153
  }
133
154
 
134
- async function decodeAndVerifyJwt(key: Uint8Array, token: string, expectedTyp: TokenHeader['typ'], audience: string) {
155
+ async function decodeAndVerifyJwt(
156
+ key: Uint8Array,
157
+ token: string,
158
+ expectedTyp: TokenHeader['typ'],
159
+ audience: string,
160
+ opts: { ignoreExpiration?: boolean } = {},
161
+ ) {
135
162
  const { payload, protectedHeader } = await jwtVerify(token, key, {
136
163
  algorithms: ['HS256'],
137
164
  audience,
165
+ ...(opts.ignoreExpiration ? { currentDate: new Date(0) } : {}),
138
166
  });
139
167
  if (protectedHeader.typ !== expectedTyp) {
140
168
  throw new InvalidToken('Unexpected token header');