@alteran/astro 0.6.3 → 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.
- package/README.md +11 -0
- package/index.js +8 -0
- package/migrations/0009_oauth_session_state.sql +31 -0
- package/migrations/meta/0009_snapshot.json +749 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/db/account.ts +134 -1
- package/src/db/schema.ts +31 -0
- package/src/lib/appview/proxy.ts +11 -8
- package/src/lib/auth.ts +34 -3
- package/src/lib/jwt.ts +4 -0
- package/src/lib/oauth/as-keys.ts +29 -0
- package/src/lib/oauth/clients.ts +453 -24
- package/src/lib/oauth/consent.ts +180 -0
- package/src/lib/oauth/dpop.ts +39 -5
- package/src/lib/oauth/resource.ts +93 -21
- package/src/lib/oauth/store.ts +64 -7
- package/src/lib/refresh-session.ts +16 -0
- package/src/lib/session-tokens.ts +33 -5
- package/src/lib/token-cleanup.ts +4 -2
- package/src/lib/util.ts +0 -1
- package/src/pages/.well-known/oauth-authorization-server.ts +16 -3
- package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
- package/src/pages/oauth/authorize.ts +31 -52
- package/src/pages/oauth/consent.ts +163 -66
- package/src/pages/oauth/jwks.ts +15 -0
- package/src/pages/oauth/par.ts +34 -56
- package/src/pages/oauth/revoke.ts +75 -0
- package/src/pages/oauth/token.ts +148 -89
- package/src/pages/xrpc/[...nsid].ts +7 -6
- package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
- package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
- package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
- package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
- package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
- package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
- package/types/env.d.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alteran/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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', {
|
package/src/lib/appview/proxy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Env } from '../../env';
|
|
2
|
-
import {
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
+
}
|