@alteran/astro 0.6.3 → 0.7.1

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 (44) hide show
  1. package/README.md +11 -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/lib/appview/proxy.ts +11 -8
  10. package/src/lib/auth.ts +34 -3
  11. package/src/lib/jwt.ts +4 -0
  12. package/src/lib/oauth/as-keys.ts +29 -0
  13. package/src/lib/oauth/clients.ts +453 -24
  14. package/src/lib/oauth/consent.ts +180 -0
  15. package/src/lib/oauth/dpop.ts +39 -5
  16. package/src/lib/oauth/resource.ts +93 -21
  17. package/src/lib/oauth/store.ts +64 -7
  18. package/src/lib/refresh-session.ts +16 -0
  19. package/src/lib/session-tokens.ts +33 -5
  20. package/src/lib/token-cleanup.ts +4 -2
  21. package/src/lib/util.ts +0 -1
  22. package/src/pages/.well-known/oauth-authorization-server.ts +15 -3
  23. package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
  24. package/src/pages/oauth/authorize.ts +31 -52
  25. package/src/pages/oauth/consent.ts +163 -66
  26. package/src/pages/oauth/jwks.ts +15 -0
  27. package/src/pages/oauth/par.ts +34 -56
  28. package/src/pages/oauth/revoke.ts +75 -0
  29. package/src/pages/oauth/token.ts +148 -89
  30. package/src/pages/xrpc/[...nsid].ts +7 -6
  31. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
  32. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
  33. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
  34. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
  35. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
  36. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
  37. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
  38. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
  39. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
  40. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
  41. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
  42. package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
  43. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
  44. package/types/env.d.ts +1 -0
@@ -64,6 +64,13 @@
64
64
  "when": 1778531816572,
65
65
  "tag": "0008_furry_ozymandias",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1778624701795,
72
+ "tag": "0009_oauth_session_state",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -29,6 +29,7 @@
29
29
  "dev": "astro dev",
30
30
  "build": "astro build",
31
31
  "preview": "astro preview",
32
+ "test:oauth": "bun test tests/oauth-*.test.ts tests/resource-auth.test.ts",
32
33
  "deploy": "astro build && bunx wrangler deploy --env production",
33
34
  "iac:deploy": "bunx alchemy deploy iac/alchemy.run.ts",
34
35
  "iac:destroy": "bunx alchemy destroy iac/alchemy.run.ts",
package/src/db/account.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { eq, or, lt } from 'drizzle-orm';
2
2
  import { getDb } from './client';
3
- import { account, refresh_token_store, secret } from './schema';
3
+ import { account, oauth_session, refresh_token_store, secret } from './schema';
4
4
  import type { Env } from '../env';
5
5
  import { normalizeHandle } from '../lib/handle';
6
6
 
@@ -8,6 +8,7 @@ const NOW = () => Math.floor(Date.now());
8
8
 
9
9
  export type AccountRow = typeof account.$inferSelect;
10
10
  export type RefreshTokenRow = typeof refresh_token_store.$inferSelect;
11
+ export type OAuthSessionRow = typeof oauth_session.$inferSelect;
11
12
 
12
13
  function normalizeIdentifier(identifier: string): { did: string | null; handle: string | null } {
13
14
  if (!identifier) return { did: null, handle: null };
@@ -73,6 +74,14 @@ export async function storeRefreshToken(env: Env, data: {
73
74
  did: string;
74
75
  expiresAt: number; // epoch seconds
75
76
  appPasswordName?: string | null;
77
+ tokenKind?: 'legacy' | 'oauth';
78
+ oauthSessionId?: string | null;
79
+ clientId?: string | null;
80
+ clientAuthMethod?: string | null;
81
+ clientAuthKeyId?: string | null;
82
+ dpopJkt?: string | null;
83
+ oauthScope?: string | null;
84
+ accessJti?: string | null;
76
85
  }): Promise<void> {
77
86
  const db = getDb(env);
78
87
  await db
@@ -83,6 +92,15 @@ export async function storeRefreshToken(env: Env, data: {
83
92
  expiresAt: data.expiresAt,
84
93
  appPasswordName: data.appPasswordName ?? null,
85
94
  nextId: null,
95
+ tokenKind: data.tokenKind ?? 'legacy',
96
+ oauthSessionId: data.oauthSessionId ?? null,
97
+ clientId: data.clientId ?? null,
98
+ clientAuthMethod: data.clientAuthMethod ?? null,
99
+ clientAuthKeyId: data.clientAuthKeyId ?? null,
100
+ dpopJkt: data.dpopJkt ?? null,
101
+ oauthScope: data.oauthScope ?? null,
102
+ accessJti: data.accessJti ?? null,
103
+ revokedAt: null,
86
104
  })
87
105
  .onConflictDoUpdate({
88
106
  target: refresh_token_store.id,
@@ -91,6 +109,15 @@ export async function storeRefreshToken(env: Env, data: {
91
109
  expiresAt: data.expiresAt,
92
110
  appPasswordName: data.appPasswordName ?? null,
93
111
  nextId: null,
112
+ tokenKind: data.tokenKind ?? 'legacy',
113
+ oauthSessionId: data.oauthSessionId ?? null,
114
+ clientId: data.clientId ?? null,
115
+ clientAuthMethod: data.clientAuthMethod ?? null,
116
+ clientAuthKeyId: data.clientAuthKeyId ?? null,
117
+ dpopJkt: data.dpopJkt ?? null,
118
+ oauthScope: data.oauthScope ?? null,
119
+ accessJti: data.accessJti ?? null,
120
+ revokedAt: null,
94
121
  },
95
122
  });
96
123
  }
@@ -119,12 +146,111 @@ export async function deleteRefreshToken(env: Env, id: string): Promise<void> {
119
146
  await db.delete(refresh_token_store).where(eq(refresh_token_store.id, id)).run();
120
147
  }
121
148
 
149
+ export async function revokeRefreshToken(env: Env, id: string, now: number = Math.floor(Date.now() / 1000)): Promise<void> {
150
+ const db = getDb(env);
151
+ await db
152
+ .update(refresh_token_store)
153
+ .set({ revokedAt: now })
154
+ .where(eq(refresh_token_store.id, id))
155
+ .run();
156
+ }
157
+
158
+ export async function markOAuthRefreshUsed(env: Env, id: string, nextId: string, now: number): Promise<void> {
159
+ const response = await env.ALTERAN_DB.prepare(
160
+ 'UPDATE refresh_token SET next_id = ?, revoked_at = ? WHERE id = ? AND token_kind = ? AND next_id IS NULL AND revoked_at IS NULL'
161
+ ).bind(nextId, now, id, 'oauth').run();
162
+ if ((response.meta.changes ?? 0) !== 1) {
163
+ throw new Error('oauth refresh token was already used');
164
+ }
165
+ }
166
+
167
+ export async function createOAuthSession(env: Env, data: {
168
+ id: string;
169
+ did: string;
170
+ clientId: string;
171
+ clientAuthMethod: string;
172
+ clientAuthKeyId?: string | null;
173
+ dpopJkt: string;
174
+ scope: string;
175
+ currentRefreshTokenId: string;
176
+ accessJti: string;
177
+ expiresAt: number;
178
+ }): Promise<void> {
179
+ const db = getDb(env);
180
+ const now = Math.floor(Date.now() / 1000);
181
+ await db
182
+ .insert(oauth_session)
183
+ .values({
184
+ id: data.id,
185
+ did: data.did,
186
+ clientId: data.clientId,
187
+ clientAuthMethod: data.clientAuthMethod,
188
+ clientAuthKeyId: data.clientAuthKeyId ?? null,
189
+ dpopJkt: data.dpopJkt,
190
+ scope: data.scope,
191
+ currentRefreshTokenId: data.currentRefreshTokenId,
192
+ accessJti: data.accessJti,
193
+ createdAt: now,
194
+ updatedAt: now,
195
+ expiresAt: data.expiresAt,
196
+ revokedAt: null,
197
+ });
198
+ }
199
+
200
+ export async function getOAuthSession(env: Env, id: string): Promise<OAuthSessionRow | null> {
201
+ const db = getDb(env);
202
+ const row = await db
203
+ .select()
204
+ .from(oauth_session)
205
+ .where(eq(oauth_session.id, id))
206
+ .get();
207
+ return row ?? null;
208
+ }
209
+
210
+ export async function updateOAuthSessionCurrent(env: Env, id: string, data: {
211
+ currentRefreshTokenId: string;
212
+ previousRefreshTokenId?: string;
213
+ accessJti: string;
214
+ expiresAt: number;
215
+ now?: number;
216
+ }): Promise<void> {
217
+ const now = data.now ?? Math.floor(Date.now() / 1000);
218
+ const response = data.previousRefreshTokenId
219
+ ? await env.ALTERAN_DB.prepare(
220
+ 'UPDATE oauth_session SET current_refresh_token_id = ?, access_jti = ?, expires_at = ?, updated_at = ? WHERE id = ? AND current_refresh_token_id = ? AND revoked_at IS NULL'
221
+ ).bind(data.currentRefreshTokenId, data.accessJti, data.expiresAt, now, id, data.previousRefreshTokenId).run()
222
+ : await env.ALTERAN_DB.prepare(
223
+ 'UPDATE oauth_session SET current_refresh_token_id = ?, access_jti = ?, expires_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL'
224
+ ).bind(data.currentRefreshTokenId, data.accessJti, data.expiresAt, now, id).run();
225
+ if ((response.meta.changes ?? 0) !== 1) {
226
+ throw new Error('oauth session changed during refresh');
227
+ }
228
+ }
229
+
230
+ export async function revokeOAuthSession(env: Env, id: string, now: number = Math.floor(Date.now() / 1000)): Promise<void> {
231
+ const db = getDb(env);
232
+ await db
233
+ .update(oauth_session)
234
+ .set({ revokedAt: now, updatedAt: now })
235
+ .where(eq(oauth_session.id, id))
236
+ .run();
237
+ }
238
+
122
239
  export async function cleanupExpiredRefreshTokens(env: Env, now: number): Promise<number> {
123
240
  const db = getDb(env);
124
241
  const response = await db.delete(refresh_token_store).where(lt(refresh_token_store.expiresAt, now)).run();
125
242
  return response.meta.changes ?? 0;
126
243
  }
127
244
 
245
+ export async function cleanupExpiredOAuthReplaySecrets(env: Env, now: number): Promise<number> {
246
+ const response = await env.ALTERAN_DB.prepare(`
247
+ DELETE FROM secret
248
+ WHERE (key LIKE 'oauth:dpop:jti:%' OR key LIKE 'oauth:client-assertion:jti:%')
249
+ AND CAST(json_extract(value, '$.exp') AS INTEGER) <= ?
250
+ `).bind(now).run();
251
+ return response.meta.changes ?? 0;
252
+ }
253
+
128
254
  export async function getSecret(env: Env, key: string): Promise<string | null> {
129
255
  const db = getDb(env);
130
256
  const row = await db.select().from(secret).where(eq(secret.key, key)).get();
@@ -142,6 +268,13 @@ export async function setSecret(env: Env, key: string, value: string): Promise<v
142
268
  });
143
269
  }
144
270
 
271
+ export async function createSecretOnce(env: Env, key: string, value: string): Promise<boolean> {
272
+ const response = await env.ALTERAN_DB.prepare(
273
+ 'INSERT OR IGNORE INTO secret (key, value, updated_at) VALUES (?, ?, ?)'
274
+ ).bind(key, value, NOW()).run();
275
+ return (response.meta.changes ?? 0) === 1;
276
+ }
277
+
145
278
  export async function getOrCreateSecret(env: Env, key: string, factory: () => Promise<string>): Promise<string> {
146
279
  // Check if secret already exists
147
280
  const existing = await getSecret(env, key);
package/src/db/schema.ts CHANGED
@@ -23,8 +23,39 @@ export const refresh_token_store = sqliteTable('refresh_token', {
23
23
  expiresAt: integer('expires_at', { mode: 'number' }).notNull(),
24
24
  appPasswordName: text('app_password_name'),
25
25
  nextId: text('next_id'),
26
+ tokenKind: text('token_kind').notNull().default('legacy'),
27
+ oauthSessionId: text('oauth_session_id'),
28
+ clientId: text('client_id'),
29
+ clientAuthMethod: text('client_auth_method'),
30
+ clientAuthKeyId: text('client_auth_key_id'),
31
+ dpopJkt: text('dpop_jkt'),
32
+ oauthScope: text('oauth_scope'),
33
+ accessJti: text('access_jti'),
34
+ revokedAt: integer('revoked_at', { mode: 'number' }),
26
35
  }, (table) => ({
27
36
  didIdx: index('refresh_token_did_idx').on(table.did),
37
+ oauthSessionIdx: index('refresh_token_oauth_session_idx').on(table.oauthSessionId),
38
+ accessJtiIdx: index('refresh_token_access_jti_idx').on(table.accessJti),
39
+ }));
40
+
41
+ export const oauth_session = sqliteTable('oauth_session', {
42
+ id: text('id').primaryKey().notNull(),
43
+ did: text('did').notNull(),
44
+ clientId: text('client_id').notNull(),
45
+ clientAuthMethod: text('client_auth_method').notNull(),
46
+ clientAuthKeyId: text('client_auth_key_id'),
47
+ dpopJkt: text('dpop_jkt').notNull(),
48
+ scope: text('scope').notNull(),
49
+ currentRefreshTokenId: text('current_refresh_token_id').notNull(),
50
+ accessJti: text('access_jti').notNull(),
51
+ createdAt: integer('created_at', { mode: 'number' }).notNull(),
52
+ updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
53
+ expiresAt: integer('expires_at', { mode: 'number' }).notNull(),
54
+ revokedAt: integer('revoked_at', { mode: 'number' }),
55
+ }, (table) => ({
56
+ clientIdx: index('oauth_session_client_idx').on(table.clientId),
57
+ currentRefreshIdx: index('oauth_session_current_refresh_idx').on(table.currentRefreshTokenId),
58
+ accessJtiIdx: index('oauth_session_access_jti_idx').on(table.accessJti),
28
59
  }));
29
60
 
30
61
  export const repo_root = sqliteTable('repo_root', {
@@ -1,5 +1,5 @@
1
1
  import type { Env } from '../../env';
2
- import { AuthTokenExpiredError, authenticateRequest, expiredToken, unauthorized } from '../auth';
2
+ import { authErrorResponse, authenticateRequest, unauthorized, type AuthContext } from '../auth';
3
3
  import { InvalidProxyHeader } from '../errors';
4
4
  import {
5
5
  PRIVILEGED_METHODS,
@@ -47,6 +47,7 @@ export interface ProxyAppViewOptions {
47
47
  readonly request: Request;
48
48
  readonly env: Env;
49
49
  readonly lxm: string;
50
+ readonly auth?: AuthContext;
50
51
  readonly fallback?: () => Promise<Response>;
51
52
  }
52
53
 
@@ -54,6 +55,7 @@ export async function proxyAppView({
54
55
  request,
55
56
  env,
56
57
  lxm,
58
+ auth: suppliedAuth,
57
59
  fallback,
58
60
  }: ProxyAppViewOptions): Promise<Response> {
59
61
  console.log('proxyAppView called:', { lxm, url: request.url });
@@ -71,14 +73,15 @@ export async function proxyAppView({
71
73
  return fallback();
72
74
  }
73
75
 
74
- let auth;
75
- try {
76
- auth = await authenticateRequest(request, env);
77
- } catch (error) {
78
- if (error instanceof AuthTokenExpiredError) {
79
- return expiredToken();
76
+ let auth: AuthContext | null | undefined = suppliedAuth;
77
+ if (!auth) {
78
+ try {
79
+ auth = await authenticateRequest(request, env);
80
+ } catch (error) {
81
+ const handled = await authErrorResponse(env, error);
82
+ if (handled) return handled;
83
+ throw error;
80
84
  }
81
- throw error;
82
85
  }
83
86
  if (!auth) {
84
87
  return unauthorized();
package/src/lib/auth.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { APIContext } from 'astro';
2
1
  import type { Env } from '../env';
3
- import { AuthTokenExpiredError } from './auth-errors';
2
+ import { AuthTokenExpiredError, expiredToken } from './auth-errors';
4
3
  import { verifyJwt, type JwtClaims } from './jwt';
4
+ import { handleResourceAuthError, verifyResourceRequestHybrid } from './oauth/resource';
5
5
  import { bearerToken } from './util';
6
6
 
7
7
  export interface AuthContext {
@@ -9,6 +9,12 @@ export interface AuthContext {
9
9
  claims: JwtClaims;
10
10
  }
11
11
 
12
+ function authScheme(request: Request): string | null {
13
+ const auth = request.headers.get('authorization');
14
+ const match = auth?.match(/^(\S+)\s+(.+)$/);
15
+ return match?.[1]?.toLowerCase() ?? null;
16
+ }
17
+
12
18
  export async function isAuthorized(request: Request, env: Env): Promise<boolean> {
13
19
  const auth = request.headers.get('authorization');
14
20
 
@@ -18,9 +24,14 @@ export async function isAuthorized(request: Request, env: Env): Promise<boolean>
18
24
  console.error('Auth Prefix:', auth?.substring(0, 30));
19
25
  console.error('=== AUTH DEBUG END ===');
20
26
 
27
+ if (authScheme(request) === 'dpop') {
28
+ const result = await verifyResourceRequestHybrid(env, request);
29
+ return !!result;
30
+ }
31
+
21
32
  const token = bearerToken(request);
22
33
  if (!token) {
23
- console.error('RESULT: No Bearer or DPoP token found');
34
+ console.error('RESULT: No Bearer token found');
24
35
  return false;
25
36
  }
26
37
 
@@ -69,7 +80,27 @@ export function unauthorized() {
69
80
  return new Response(JSON.stringify({ error: 'AuthRequired' }), { status: 401 });
70
81
  }
71
82
 
83
+ export async function authErrorResponse(env: Env, error: unknown): Promise<Response | null> {
84
+ if (error instanceof AuthTokenExpiredError) {
85
+ return expiredToken();
86
+ }
87
+ return handleResourceAuthError(env, error);
88
+ }
89
+
72
90
  export async function authenticateRequest(request: Request, env: Env): Promise<AuthContext | null> {
91
+ if (authScheme(request) === 'dpop') {
92
+ const result = await verifyResourceRequestHybrid(env, request);
93
+ if (!result) return null;
94
+ return {
95
+ token: result.token,
96
+ claims: {
97
+ sub: result.did,
98
+ scope: result.scope,
99
+ t: 'access',
100
+ } as JwtClaims,
101
+ };
102
+ }
103
+
73
104
  const token = bearerToken(request);
74
105
  if (!token) return null;
75
106
  let ver;
package/src/lib/jwt.ts CHANGED
@@ -73,6 +73,10 @@ export async function verifyJwt(
73
73
  console.error('[verifyJwt] No sub in payload');
74
74
  return null;
75
75
  }
76
+ if ((payload as any).cnf) {
77
+ console.error('[verifyJwt] Rejecting DPoP-bound OAuth token on Bearer path');
78
+ return null;
79
+ }
76
80
  const claims: JwtClaims = {
77
81
  sub: String(payload.sub),
78
82
  aud: payload.aud as string | undefined,
@@ -0,0 +1,29 @@
1
+ import type { Env } from '../../env';
2
+ import { getOrCreateSecret } from '../../db/account';
3
+ import { jwkThumbprint } from './dpop';
4
+
5
+ const AS_SIGNING_KEY = 'oauth:as:signing-key';
6
+
7
+ export async function getAuthorizationServerPublicJwk(env: Env): Promise<JsonWebKey> {
8
+ const privateJwkJson = await getOrCreateSecret(env, AS_SIGNING_KEY, async () => {
9
+ const keyPair = await crypto.subtle.generateKey(
10
+ { name: 'ECDSA', namedCurve: 'P-256' },
11
+ true,
12
+ ['sign', 'verify'],
13
+ );
14
+ const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
15
+ return JSON.stringify(privateJwk);
16
+ });
17
+
18
+ const privateJwk = JSON.parse(privateJwkJson) as JsonWebKey;
19
+ const publicJwk: JsonWebKey = {
20
+ kty: privateJwk.kty,
21
+ crv: privateJwk.crv,
22
+ x: privateJwk.x,
23
+ y: privateJwk.y,
24
+ key_ops: ['verify'],
25
+ ext: true,
26
+ };
27
+ const kid = await jwkThumbprint(publicJwk);
28
+ return { ...publicJwk, kid, alg: 'ES256', use: 'sig' } as JsonWebKey;
29
+ }