@hatk/hatk 0.0.1-alpha.8 → 0.0.1-alpha.81
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/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +111 -0
- package/dist/backfill.d.ts +29 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +136 -51
- package/dist/car.d.ts +42 -10
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +154 -14
- package/dist/cli.js +243 -1043
- package/dist/config.d.ts +82 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +44 -9
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +16 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +76 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/{db.d.ts → database/db.d.ts} +57 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/{db.js → database/db.js} +755 -552
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +116 -32
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +81 -41
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +125 -0
- package/dist/feeds.d.ts +12 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +51 -6
- package/dist/hooks.d.ts +85 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +163 -0
- package/dist/hydrate.d.ts +7 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +4 -16
- package/dist/indexer.d.ts +133 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +406 -66
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +225 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
- package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +169 -74
- package/dist/mst.d.ts +18 -1
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +19 -8
- package/dist/oauth/db.d.ts +3 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +56 -19
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +13 -1
- package/dist/oauth/server.d.ts +92 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +352 -37
- package/dist/oauth/service-describe.d.ts +31 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +61 -0
- package/dist/oauth/session.d.ts +32 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +131 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +80 -40
- package/dist/pds-proxy.d.ts +82 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +356 -0
- package/dist/private-collections.d.ts +8 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +22 -0
- package/dist/push.d.ts +54 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +367 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +88 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +62 -0
- package/dist/server.d.ts +26 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +858 -638
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/templates/feed.tpl +14 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +41 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +41 -32
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -66
- package/dist/xrpc.d.ts +84 -11
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +211 -41
- package/package.json +17 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +177 -61
- package/dist/db.d.ts.map +0 -1
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- package/dist/test-browser.js +0 -26
package/dist/oauth/server.js
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
// packages/hatk/src/oauth/server.ts
|
|
2
2
|
import { generateKeyPair, importPrivateKey, computeJwkThumbprint, signJwt, parseJwt, verifyEs256, importPublicKey, randomToken, sha256, base64UrlEncode, } from "./crypto.js";
|
|
3
3
|
import { parseDpopProof, createDpopProof } from "./dpop.js";
|
|
4
|
+
import { initSession } from "./session.js";
|
|
4
5
|
import { resolveClient, validateRedirectUri, isLoopbackClient } from "./client.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { negotiateScope } from "./service-describe.js";
|
|
7
|
+
import { discoverAuthServer, resolveHandle, fetchProtectedResourceMetadata, fetchAuthServerMetadata, } from "./discovery.js";
|
|
8
|
+
import { getServerKey, storeServerKey, storeOAuthRequest, getOAuthRequest, deleteOAuthRequest, storeAuthCode, consumeAuthCode, storeSession, deleteSession, checkAndStoreDpopJti, cleanupExpiredOAuth, storeRefreshToken, getRefreshToken, revokeRefreshToken, } from "./db.js";
|
|
7
9
|
import { emit } from "../logger.js";
|
|
8
|
-
import { querySQL } from "../db.js";
|
|
9
|
-
import { fireOnLoginHook } from "
|
|
10
|
+
import { querySQL } from "../database/db.js";
|
|
11
|
+
import { fireOnLoginHook } from "../hooks.js";
|
|
10
12
|
const SERVER_KEY_KID = 'appview-oauth-key';
|
|
13
|
+
const CLIENT_KEY_KID = 'pds-client-auth-key';
|
|
14
|
+
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
15
|
+
/**
|
|
16
|
+
* RFC 6749 §5.2 OAuth token-endpoint error.
|
|
17
|
+
*
|
|
18
|
+
* Surfaces as HTTP `status` (default 400) with body
|
|
19
|
+
* `{ error: code, error_description: description }` so OAuth clients can
|
|
20
|
+
* distinguish a permanently-rejected refresh token (`invalid_grant`) from
|
|
21
|
+
* a transient server failure and decide whether to log the user out.
|
|
22
|
+
*/
|
|
23
|
+
export class OAuthError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
description;
|
|
26
|
+
status;
|
|
27
|
+
constructor(code, description, status = 400) {
|
|
28
|
+
super(description);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.description = description;
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.name = 'OAuthError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
11
35
|
async function resolveHandleForDid(did) {
|
|
12
36
|
const rows = (await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]));
|
|
13
37
|
return rows[0]?.handle || undefined;
|
|
@@ -38,6 +62,8 @@ let serverPrivateJwk;
|
|
|
38
62
|
let serverPublicJwk;
|
|
39
63
|
let serverPrivateKey;
|
|
40
64
|
let serverJkt;
|
|
65
|
+
let clientPublicJwk;
|
|
66
|
+
let clientPrivateKey;
|
|
41
67
|
let _plcUrl;
|
|
42
68
|
let _relayUrl;
|
|
43
69
|
export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
@@ -57,6 +83,26 @@ export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
|
57
83
|
}
|
|
58
84
|
serverPrivateKey = await importPrivateKey(serverPrivateJwk);
|
|
59
85
|
serverJkt = await computeJwkThumbprint(serverPublicJwk);
|
|
86
|
+
// Client-authentication key, kept separate from the access-token signing key
|
|
87
|
+
// above. A PDS binds each session to the kid that authenticated it and
|
|
88
|
+
// refuses a later refresh signed by a different one, so this key can't be
|
|
89
|
+
// rotated without signing every user out — keeping it distinct means the
|
|
90
|
+
// access-token key stays rotatable.
|
|
91
|
+
const existingClientKey = await getServerKey(CLIENT_KEY_KID);
|
|
92
|
+
let clientPrivateJwk;
|
|
93
|
+
if (existingClientKey) {
|
|
94
|
+
clientPrivateJwk = JSON.parse(existingClientKey.privateKey);
|
|
95
|
+
clientPublicJwk = JSON.parse(existingClientKey.publicKey);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const kp = await generateKeyPair();
|
|
99
|
+
clientPrivateJwk = kp.privateJwk;
|
|
100
|
+
clientPublicJwk = kp.publicJwk;
|
|
101
|
+
await storeServerKey(CLIENT_KEY_KID, JSON.stringify(clientPrivateJwk), JSON.stringify(clientPublicJwk));
|
|
102
|
+
}
|
|
103
|
+
clientPrivateKey = await importPrivateKey(clientPrivateJwk);
|
|
104
|
+
// Initialize SSR session cookie signing
|
|
105
|
+
initSession(serverPrivateJwk, _config.cookieName);
|
|
60
106
|
// Periodic cleanup of expired OAuth data
|
|
61
107
|
setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
|
|
62
108
|
}
|
|
@@ -103,22 +149,96 @@ export function getJwks() {
|
|
|
103
149
|
],
|
|
104
150
|
};
|
|
105
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether we authenticate to PDSes as a confidential client.
|
|
154
|
+
*
|
|
155
|
+
* A confidential client gets a 2-year session and 3-month refresh window
|
|
156
|
+
* instead of 2 weeks, and is exempt from the consent screen atproto forces on
|
|
157
|
+
* every authorization request from a public client. It requires a fetchable
|
|
158
|
+
* `jwks_uri`, so loopback deployments (local dev) stay public.
|
|
159
|
+
*/
|
|
160
|
+
export function isConfidentialClient(issuer) {
|
|
161
|
+
return !isLoopbackClient(issuer);
|
|
162
|
+
}
|
|
163
|
+
/** Public half of the client-authentication key, for the PDS to verify assertions. */
|
|
164
|
+
export function getClientJwks() {
|
|
165
|
+
return {
|
|
166
|
+
keys: [
|
|
167
|
+
{
|
|
168
|
+
...clientPublicJwk,
|
|
169
|
+
kid: CLIENT_KEY_KID,
|
|
170
|
+
use: 'sig',
|
|
171
|
+
alg: 'ES256',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A JWT proving we are this client, per RFC 7523. The PDS checks `sub` against
|
|
178
|
+
* the client_id it resolved and `aud` against its own issuer, then verifies the
|
|
179
|
+
* signature against the key it fetched from `jwks_uri`.
|
|
180
|
+
*/
|
|
181
|
+
async function createClientAssertion(issuer, config, audience) {
|
|
182
|
+
const clientId = pdsClientId(issuer, config);
|
|
183
|
+
const now = Math.floor(Date.now() / 1000);
|
|
184
|
+
return signJwt({ typ: 'JWT', alg: 'ES256', kid: CLIENT_KEY_KID }, {
|
|
185
|
+
iss: clientId,
|
|
186
|
+
sub: clientId,
|
|
187
|
+
aud: audience,
|
|
188
|
+
jti: randomToken(),
|
|
189
|
+
iat: now,
|
|
190
|
+
exp: now + 60,
|
|
191
|
+
}, clientPrivateKey);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Add client authentication to a PAR or token request body. No-op for loopback
|
|
195
|
+
* deployments, which stay public clients.
|
|
196
|
+
*/
|
|
197
|
+
async function withClientAuth(params, issuer, config, audience) {
|
|
198
|
+
if (!isConfidentialClient(issuer))
|
|
199
|
+
return params;
|
|
200
|
+
return {
|
|
201
|
+
...params,
|
|
202
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
203
|
+
client_assertion: await createClientAssertion(issuer, config, audience),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
106
206
|
export function getClientMetadata(issuer, config) {
|
|
107
207
|
// Find the metadata client entry to get its scope
|
|
108
208
|
const metadataClientId = `${issuer}/oauth-client-metadata.json`;
|
|
109
209
|
const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
|
|
210
|
+
const confidential = isConfidentialClient(issuer);
|
|
110
211
|
return {
|
|
111
212
|
client_id: metadataClientId,
|
|
112
213
|
client_name: clientConfig?.client_name || 'hatk',
|
|
113
214
|
redirect_uris: [`${issuer}/oauth/callback`],
|
|
114
215
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
115
216
|
response_types: ['code'],
|
|
116
|
-
|
|
217
|
+
// atproto requires the signing alg alongside private_key_jwt, and requires
|
|
218
|
+
// its absence for a public client.
|
|
219
|
+
...(confidential
|
|
220
|
+
? {
|
|
221
|
+
token_endpoint_auth_method: 'private_key_jwt',
|
|
222
|
+
token_endpoint_auth_signing_alg: 'ES256',
|
|
223
|
+
jwks_uri: `${issuer}/oauth/client-jwks.json`,
|
|
224
|
+
}
|
|
225
|
+
: { token_endpoint_auth_method: 'none' }),
|
|
117
226
|
dpop_bound_access_tokens: true,
|
|
118
227
|
scope: clientConfig?.scope || 'atproto transition:generic',
|
|
119
228
|
};
|
|
120
229
|
}
|
|
121
230
|
// --- PAR Endpoint ---
|
|
231
|
+
/**
|
|
232
|
+
* Handle a Pushed Authorization Request (PAR).
|
|
233
|
+
*
|
|
234
|
+
* Supports account creation via `prompt=create`. When set, `login_hint`
|
|
235
|
+
* is treated as a PDS hostname (e.g. "selfhosted.social" or "localhost:2583")
|
|
236
|
+
* rather than a handle or DID. The auth server is discovered from the PDS's
|
|
237
|
+
* protected resource metadata, and `prompt=create` is forwarded to the PDS
|
|
238
|
+
* PAR so it shows the signup page.
|
|
239
|
+
*
|
|
240
|
+
* For normal login, `login_hint` is a handle or DID as usual.
|
|
241
|
+
*/
|
|
122
242
|
export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
123
243
|
// Validate client DPoP proof
|
|
124
244
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
@@ -143,36 +263,73 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
143
263
|
throw new Error('code_challenge is required');
|
|
144
264
|
if (body.code_challenge_method && body.code_challenge_method !== 'S256')
|
|
145
265
|
throw new Error('Only S256 supported');
|
|
146
|
-
// Resolve DID from login_hint
|
|
266
|
+
// Resolve DID and PDS from login_hint
|
|
267
|
+
const prompt = body.prompt;
|
|
147
268
|
let did = body.login_hint;
|
|
148
|
-
if (did && !did.startsWith('did:')) {
|
|
149
|
-
did = await resolveHandle(did, _relayUrl);
|
|
150
|
-
}
|
|
151
|
-
// Discover user's PDS auth server
|
|
152
269
|
let pdsRequestUri;
|
|
153
270
|
let pdsAuthServer;
|
|
154
271
|
let pdsCodeVerifier;
|
|
155
272
|
let pdsState;
|
|
156
|
-
|
|
273
|
+
let pdsEndpoint;
|
|
274
|
+
if (prompt === 'create' && body.login_hint) {
|
|
275
|
+
// Account creation: login_hint is a PDS URL, discover auth server from it directly
|
|
276
|
+
let pdsUrl;
|
|
277
|
+
if (body.login_hint.startsWith('http')) {
|
|
278
|
+
pdsUrl = body.login_hint;
|
|
279
|
+
}
|
|
280
|
+
else if (body.login_hint.match(/^localhost[:/]/)) {
|
|
281
|
+
pdsUrl = `http://${body.login_hint}`;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
pdsUrl = `https://${body.login_hint}`;
|
|
285
|
+
}
|
|
286
|
+
pdsEndpoint = pdsUrl;
|
|
287
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
288
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
289
|
+
if (!pdsAuthServer)
|
|
290
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
291
|
+
did = undefined; // no DID yet for account creation
|
|
292
|
+
}
|
|
293
|
+
else if (did && !did.startsWith('did:')) {
|
|
294
|
+
try {
|
|
295
|
+
did = await resolveHandle(did, _relayUrl);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new Error('Handle not found');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Discover user's PDS auth server (for login flow with a resolved DID)
|
|
302
|
+
if (did && !pdsAuthServer) {
|
|
157
303
|
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
158
304
|
pdsAuthServer = discovery.authServerEndpoint;
|
|
305
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
306
|
+
}
|
|
307
|
+
if (pdsAuthServer) {
|
|
308
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
159
309
|
// Create PKCE for our PAR to the PDS
|
|
160
310
|
pdsCodeVerifier = randomToken();
|
|
161
311
|
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
162
312
|
pdsState = randomToken(); // unique state to correlate callback
|
|
163
313
|
// PAR to the PDS
|
|
164
|
-
const parEndpoint =
|
|
314
|
+
const parEndpoint = authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
|
|
165
315
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
166
|
-
const
|
|
316
|
+
const pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
317
|
+
const pdsParParams = {
|
|
167
318
|
client_id: pdsClientId(config.issuer, config),
|
|
168
319
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
169
320
|
response_type: 'code',
|
|
170
321
|
code_challenge: pdsCodeChallenge,
|
|
171
322
|
code_challenge_method: 'S256',
|
|
172
|
-
scope:
|
|
173
|
-
login_hint: body.login_hint || did,
|
|
323
|
+
scope: pdsScope,
|
|
174
324
|
state: pdsState,
|
|
175
|
-
}
|
|
325
|
+
};
|
|
326
|
+
if (prompt === 'create') {
|
|
327
|
+
pdsParParams.prompt = 'create';
|
|
328
|
+
}
|
|
329
|
+
if (did) {
|
|
330
|
+
pdsParParams.login_hint = body.login_hint || did;
|
|
331
|
+
}
|
|
332
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
176
333
|
const pdsParRes = await fetch(parEndpoint, {
|
|
177
334
|
method: 'POST',
|
|
178
335
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -234,6 +391,7 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
234
391
|
dpopJkt: dpop.jkt,
|
|
235
392
|
pdsRequestUri,
|
|
236
393
|
pdsAuthServer,
|
|
394
|
+
pdsEndpoint,
|
|
237
395
|
pdsCodeVerifier,
|
|
238
396
|
pdsState,
|
|
239
397
|
did,
|
|
@@ -253,10 +411,130 @@ export function buildAuthorizeRedirect(config, request) {
|
|
|
253
411
|
});
|
|
254
412
|
return `${request.pds_auth_server}/oauth/authorize?${params}`;
|
|
255
413
|
}
|
|
414
|
+
// --- Server-initiated login (no DPoP required from browser) ---
|
|
415
|
+
/**
|
|
416
|
+
* Initiate a server-side OAuth login or account creation flow.
|
|
417
|
+
*
|
|
418
|
+
* For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
|
|
419
|
+
* The `pds` is a bare hostname; the auth server is discovered from its
|
|
420
|
+
* protected resource metadata.
|
|
421
|
+
*/
|
|
422
|
+
export async function serverLogin(config, handle, options) {
|
|
423
|
+
let did;
|
|
424
|
+
let pdsAuthServer;
|
|
425
|
+
let pdsEndpoint;
|
|
426
|
+
if (options?.prompt === 'create' && options?.pds) {
|
|
427
|
+
// Account creation: discover auth server from PDS hostname
|
|
428
|
+
const pdsUrl = options.pds.startsWith('http')
|
|
429
|
+
? options.pds
|
|
430
|
+
: options.pds.match(/^localhost[:/]/)
|
|
431
|
+
? `http://${options.pds}`
|
|
432
|
+
: `https://${options.pds}`;
|
|
433
|
+
pdsEndpoint = pdsUrl;
|
|
434
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
435
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
436
|
+
if (!pdsAuthServer)
|
|
437
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
// Normal login: resolve handle to DID
|
|
441
|
+
did = handle;
|
|
442
|
+
if (!did.startsWith('did:')) {
|
|
443
|
+
did = await resolveHandle(handle, _relayUrl);
|
|
444
|
+
}
|
|
445
|
+
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
446
|
+
pdsAuthServer = discovery.authServerEndpoint;
|
|
447
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
448
|
+
}
|
|
449
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
450
|
+
// Create PKCE for PAR to PDS
|
|
451
|
+
const pdsCodeVerifier = randomToken();
|
|
452
|
+
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
453
|
+
const pdsState = randomToken();
|
|
454
|
+
// PAR to the PDS
|
|
455
|
+
const parEndpoint = authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
|
|
456
|
+
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
457
|
+
const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
458
|
+
const pdsParParams = {
|
|
459
|
+
client_id: pdsClientId(config.issuer, config),
|
|
460
|
+
redirect_uri: pdsRedirectUri(config.issuer),
|
|
461
|
+
response_type: 'code',
|
|
462
|
+
code_challenge: pdsCodeChallenge,
|
|
463
|
+
code_challenge_method: 'S256',
|
|
464
|
+
scope,
|
|
465
|
+
state: pdsState,
|
|
466
|
+
};
|
|
467
|
+
if (options?.prompt === 'create') {
|
|
468
|
+
pdsParParams.prompt = 'create';
|
|
469
|
+
}
|
|
470
|
+
if (did) {
|
|
471
|
+
pdsParParams.login_hint = handle;
|
|
472
|
+
}
|
|
473
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
474
|
+
let pdsRequestUri;
|
|
475
|
+
const pdsParRes = await fetch(parEndpoint, {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
478
|
+
body: pdsParBody.toString(),
|
|
479
|
+
});
|
|
480
|
+
if (!pdsParRes.ok) {
|
|
481
|
+
const errBody = await pdsParRes.json().catch(() => ({}));
|
|
482
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
483
|
+
const nonce = pdsParRes.headers.get('DPoP-Nonce');
|
|
484
|
+
if (nonce) {
|
|
485
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
|
|
486
|
+
const retryRes = await fetch(parEndpoint, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
489
|
+
body: pdsParBody.toString(),
|
|
490
|
+
});
|
|
491
|
+
if (!retryRes.ok) {
|
|
492
|
+
const retryErr = await retryRes.json().catch(() => ({}));
|
|
493
|
+
throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
|
|
494
|
+
}
|
|
495
|
+
const retryData = await retryRes.json();
|
|
496
|
+
pdsRequestUri = retryData.request_uri;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const pdsParData = await pdsParRes.json();
|
|
505
|
+
pdsRequestUri = pdsParData.request_uri;
|
|
506
|
+
}
|
|
507
|
+
// Store the request so the callback can find it
|
|
508
|
+
const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
|
|
509
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 600;
|
|
510
|
+
await storeOAuthRequest(requestUri, {
|
|
511
|
+
clientId: pdsClientId(config.issuer, config),
|
|
512
|
+
redirectUri: '/',
|
|
513
|
+
scope,
|
|
514
|
+
state: pdsState,
|
|
515
|
+
codeChallenge: '',
|
|
516
|
+
codeChallengeMethod: 'S256',
|
|
517
|
+
dpopJkt: serverJkt,
|
|
518
|
+
pdsRequestUri,
|
|
519
|
+
pdsAuthServer,
|
|
520
|
+
pdsEndpoint,
|
|
521
|
+
pdsCodeVerifier,
|
|
522
|
+
pdsState,
|
|
523
|
+
did,
|
|
524
|
+
loginHint: handle,
|
|
525
|
+
expiresAt,
|
|
526
|
+
});
|
|
527
|
+
// Build redirect URL to PDS
|
|
528
|
+
const params = new URLSearchParams({
|
|
529
|
+
request_uri: pdsRequestUri,
|
|
530
|
+
client_id: pdsClientId(config.issuer, config),
|
|
531
|
+
});
|
|
532
|
+
return `${pdsAuthServer}/oauth/authorize?${params}`;
|
|
533
|
+
}
|
|
256
534
|
// --- OAuth Callback (PDS redirects here) ---
|
|
257
535
|
export async function handleCallback(config, code, state, iss) {
|
|
258
536
|
// Find the matching OAuth request by pds_state (unique per PAR)
|
|
259
|
-
const { querySQL } = await import("../db.js");
|
|
537
|
+
const { querySQL } = await import("../database/db.js");
|
|
260
538
|
let request = null;
|
|
261
539
|
if (state) {
|
|
262
540
|
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
|
|
@@ -275,13 +553,13 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
275
553
|
// Exchange code at PDS token endpoint
|
|
276
554
|
const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
|
|
277
555
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
278
|
-
const tokenBody = new URLSearchParams({
|
|
556
|
+
const tokenBody = new URLSearchParams(await withClientAuth({
|
|
279
557
|
grant_type: 'authorization_code',
|
|
280
558
|
code,
|
|
281
559
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
282
560
|
client_id: pdsClientId(config.issuer, config),
|
|
283
561
|
code_verifier: request.pds_code_verifier,
|
|
284
|
-
});
|
|
562
|
+
}, config.issuer, config, request.pds_auth_server));
|
|
285
563
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
286
564
|
method: 'POST',
|
|
287
565
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -329,42 +607,75 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
329
607
|
const did = tokenData.sub;
|
|
330
608
|
if (!did)
|
|
331
609
|
throw new Error('PDS token response missing sub (DID)');
|
|
332
|
-
// Store PDS session server-side
|
|
610
|
+
// Store PDS session server-side — pds_endpoint is the actual data PDS
|
|
611
|
+
// (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
|
|
333
612
|
await storeSession(did, {
|
|
334
|
-
pdsEndpoint: request.
|
|
613
|
+
pdsEndpoint: request.pds_endpoint,
|
|
614
|
+
pdsAuthServer: request.pds_auth_server,
|
|
335
615
|
accessToken: tokenData.access_token,
|
|
336
616
|
refreshToken: tokenData.refresh_token,
|
|
337
617
|
dpopJkt: serverJkt,
|
|
338
618
|
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
339
619
|
});
|
|
340
|
-
await fireOnLoginHook(did);
|
|
620
|
+
await fireOnLoginHook(did, config);
|
|
341
621
|
// Generate authorization code for the client
|
|
342
622
|
const clientCode = randomToken();
|
|
343
623
|
await storeAuthCode(clientCode, request.request_uri);
|
|
344
624
|
// Update the request with the DID (in case it wasn't set during PAR)
|
|
345
625
|
if (!request.did && did) {
|
|
346
|
-
const { runSQL } = await import("../db.js");
|
|
347
|
-
await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', did, request.request_uri);
|
|
626
|
+
const { runSQL } = await import("../database/db.js");
|
|
627
|
+
await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
|
|
348
628
|
}
|
|
349
629
|
// Build redirect back to client
|
|
350
630
|
const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
|
|
351
631
|
if (request.state)
|
|
352
632
|
params.set('state', request.state);
|
|
353
633
|
const clientRedirectUri = `${request.redirect_uri}?${params}`;
|
|
354
|
-
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
|
|
634
|
+
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Handle an error redirect from the PDS (user denied, request expired, etc).
|
|
638
|
+
*
|
|
639
|
+
* The PDS reports failures by redirecting to our callback with `error` and no
|
|
640
|
+
* `code`. Discard the pending request and return a URL that hands the error
|
|
641
|
+
* back to the app, so the browser lands somewhere renderable instead of on a
|
|
642
|
+
* raw error response.
|
|
643
|
+
*/
|
|
644
|
+
export async function handleCallbackError(config, error, errorDescription, state) {
|
|
645
|
+
let request = null;
|
|
646
|
+
if (state) {
|
|
647
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
|
|
648
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
649
|
+
}
|
|
650
|
+
if (request)
|
|
651
|
+
await deleteOAuthRequest(request.request_uri);
|
|
652
|
+
emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
|
|
653
|
+
const params = new URLSearchParams({ error });
|
|
654
|
+
if (errorDescription)
|
|
655
|
+
params.set('error_description', errorDescription);
|
|
656
|
+
// Server-initiated logins store redirect_uri as '/' — send the browser home
|
|
657
|
+
// with the error attached. Same for an unmatched state (expired or replayed).
|
|
658
|
+
if (!request || request.redirect_uri === '/')
|
|
659
|
+
return `/?${params}`;
|
|
660
|
+
// Client flow: echo the client's state and our issuer so the SPA's callback
|
|
661
|
+
// handler recognises the redirect as its own rather than re-entering here.
|
|
662
|
+
if (request.state)
|
|
663
|
+
params.set('state', request.state);
|
|
664
|
+
params.set('iss', config.issuer);
|
|
665
|
+
return `${request.redirect_uri}?${params}`;
|
|
355
666
|
}
|
|
356
667
|
// --- Token Endpoint ---
|
|
357
668
|
export async function handleToken(config, body, dpopHeader, requestUrl) {
|
|
358
669
|
const grantType = body.grant_type;
|
|
359
670
|
if (!grantType)
|
|
360
|
-
throw new
|
|
671
|
+
throw new OAuthError('invalid_request', 'grant_type is required');
|
|
361
672
|
if (grantType === 'authorization_code') {
|
|
362
673
|
return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
|
|
363
674
|
}
|
|
364
675
|
else if (grantType === 'refresh_token') {
|
|
365
676
|
return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
|
|
366
677
|
}
|
|
367
|
-
throw new
|
|
678
|
+
throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
|
|
368
679
|
}
|
|
369
680
|
async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
|
|
370
681
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
@@ -437,20 +748,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
437
748
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
438
749
|
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
439
750
|
if (!fresh)
|
|
440
|
-
throw new
|
|
751
|
+
throw new OAuthError('invalid_request', 'DPoP jti replay detected');
|
|
441
752
|
const { refresh_token, client_id } = body;
|
|
442
753
|
if (!refresh_token || !client_id)
|
|
443
|
-
throw new
|
|
444
|
-
// Look up and validate refresh token
|
|
754
|
+
throw new OAuthError('invalid_request', 'Missing required parameters');
|
|
755
|
+
// Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
|
|
756
|
+
// `invalid_grant` — the client must treat them as terminal and re-authenticate.
|
|
445
757
|
const stored = await getRefreshToken(refresh_token);
|
|
446
758
|
if (!stored)
|
|
447
|
-
throw new
|
|
759
|
+
throw new OAuthError('invalid_grant', 'Invalid refresh token');
|
|
448
760
|
if (stored.revoked)
|
|
449
|
-
throw new
|
|
761
|
+
throw new OAuthError('invalid_grant', 'Refresh token revoked');
|
|
450
762
|
if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
|
|
451
|
-
throw new
|
|
763
|
+
throw new OAuthError('invalid_grant', 'Refresh token expired');
|
|
452
764
|
if (stored.client_id !== client_id)
|
|
453
|
-
throw new
|
|
765
|
+
throw new OAuthError('invalid_grant', 'client_id mismatch');
|
|
454
766
|
// Revoke old refresh token (rotation)
|
|
455
767
|
await revokeRefreshToken(refresh_token);
|
|
456
768
|
const did = stored.did;
|
|
@@ -492,14 +804,15 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
492
804
|
export async function refreshPdsSession(config, session) {
|
|
493
805
|
if (!session.refresh_token)
|
|
494
806
|
return null;
|
|
495
|
-
|
|
807
|
+
// Use auth server for token endpoint (falls back to pds_endpoint for sessions created before this fix)
|
|
808
|
+
const tokenEndpoint = `${session.pds_auth_server || session.pds_endpoint}/oauth/token`;
|
|
496
809
|
const clientId = pdsClientId(config.issuer, config);
|
|
497
810
|
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
498
|
-
const body = new URLSearchParams({
|
|
811
|
+
const body = new URLSearchParams(await withClientAuth({
|
|
499
812
|
grant_type: 'refresh_token',
|
|
500
813
|
refresh_token: session.refresh_token,
|
|
501
814
|
client_id: clientId,
|
|
502
|
-
});
|
|
815
|
+
}, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
|
|
503
816
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
504
817
|
method: 'POST',
|
|
505
818
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
|
|
@@ -526,12 +839,14 @@ export async function refreshPdsSession(config, session) {
|
|
|
526
839
|
did: session.did,
|
|
527
840
|
pds_endpoint: session.pds_endpoint,
|
|
528
841
|
});
|
|
842
|
+
await deleteSession(session.did);
|
|
529
843
|
return null;
|
|
530
844
|
}
|
|
531
845
|
const tokenData = await tokenRes.json();
|
|
532
846
|
// Update stored session
|
|
533
847
|
await storeSession(session.did, {
|
|
534
848
|
pdsEndpoint: session.pds_endpoint,
|
|
849
|
+
pdsAuthServer: session.pds_auth_server,
|
|
535
850
|
accessToken: tokenData.access_token,
|
|
536
851
|
refreshToken: tokenData.refresh_token || session.refresh_token,
|
|
537
852
|
dpopJkt: session.dpop_jkt,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a PDS says it serves, and the scopes worth asking it for.
|
|
3
|
+
*
|
|
4
|
+
* `community.lexicon.service.describe` answers with a service's roles and every
|
|
5
|
+
* XRPC method it routes. It is the only way to ask: atproto publishes no method
|
|
6
|
+
* list, so the alternative is to call a method and read the failure, where a
|
|
7
|
+
* server without an optional feature and one that is broken look alike.
|
|
8
|
+
*
|
|
9
|
+
* Used before pushing an authorization request, so a PDS is asked for a
|
|
10
|
+
* feature's scopes only when it implements the feature.
|
|
11
|
+
*/
|
|
12
|
+
import type { OAuthConfig } from '../config.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Every XRPC method a PDS serves. An empty set for a server that answers no
|
|
15
|
+
* describe — which is every PDS that predates the method, so absence has to
|
|
16
|
+
* read as "no optional features" rather than as an error.
|
|
17
|
+
*/
|
|
18
|
+
export declare function describeMethods(pdsEndpoint: string): Promise<Set<string>>;
|
|
19
|
+
/**
|
|
20
|
+
* The scope string to request from this PDS: `base`, plus any conditional set
|
|
21
|
+
* whose `whenMethod` the server serves.
|
|
22
|
+
*
|
|
23
|
+
* Loopback clients are left alone. Their client_id encodes the scope they may
|
|
24
|
+
* request, and the token exchange rebuilds that client_id from config — vary
|
|
25
|
+
* the request and the two disagree. A local setup that needs the extra scopes
|
|
26
|
+
* lists them in its client's own `scope` instead.
|
|
27
|
+
*/
|
|
28
|
+
export declare function negotiateScope(config: OAuthConfig, base: string, pdsEndpoint: string | undefined, isLoopback: boolean): Promise<string>;
|
|
29
|
+
/** Drop cached describe answers. For tests. */
|
|
30
|
+
export declare function clearDescribeCache(): void;
|
|
31
|
+
//# sourceMappingURL=service-describe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAU/C;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAwB/E;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { emit } from "../logger.js";
|
|
2
|
+
const DESCRIBE_NSID = 'community.lexicon.service.describe';
|
|
3
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
4
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
5
|
+
/** Answers are a property of the server, so one entry covers every account on it. */
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Every XRPC method a PDS serves. An empty set for a server that answers no
|
|
9
|
+
* describe — which is every PDS that predates the method, so absence has to
|
|
10
|
+
* read as "no optional features" rather than as an error.
|
|
11
|
+
*/
|
|
12
|
+
export async function describeMethods(pdsEndpoint) {
|
|
13
|
+
const cached = cache.get(pdsEndpoint);
|
|
14
|
+
if (cached && cached.expires > Date.now())
|
|
15
|
+
return cached.methods;
|
|
16
|
+
let methods = new Set();
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${pdsEndpoint.replace(/\/$/, '')}/xrpc/${DESCRIBE_NSID}`, {
|
|
19
|
+
headers: { accept: 'application/json' },
|
|
20
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
21
|
+
});
|
|
22
|
+
if (res.ok) {
|
|
23
|
+
const body = (await res.json().catch(() => null));
|
|
24
|
+
if (Array.isArray(body?.methods)) {
|
|
25
|
+
for (const m of body.methods) {
|
|
26
|
+
if (typeof m?.value === 'string')
|
|
27
|
+
methods.add(m.value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
methods = new Set();
|
|
34
|
+
}
|
|
35
|
+
cache.set(pdsEndpoint, { methods, expires: Date.now() + CACHE_TTL_MS });
|
|
36
|
+
return methods;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The scope string to request from this PDS: `base`, plus any conditional set
|
|
40
|
+
* whose `whenMethod` the server serves.
|
|
41
|
+
*
|
|
42
|
+
* Loopback clients are left alone. Their client_id encodes the scope they may
|
|
43
|
+
* request, and the token exchange rebuilds that client_id from config — vary
|
|
44
|
+
* the request and the two disagree. A local setup that needs the extra scopes
|
|
45
|
+
* lists them in its client's own `scope` instead.
|
|
46
|
+
*/
|
|
47
|
+
export async function negotiateScope(config, base, pdsEndpoint, isLoopback) {
|
|
48
|
+
const conditional = config.conditionalScopes ?? [];
|
|
49
|
+
if (conditional.length === 0 || !pdsEndpoint || isLoopback)
|
|
50
|
+
return base;
|
|
51
|
+
const methods = await describeMethods(pdsEndpoint);
|
|
52
|
+
const granted = conditional.filter((c) => methods.has(c.whenMethod)).flatMap((c) => c.scopes);
|
|
53
|
+
if (granted.length === 0)
|
|
54
|
+
return base;
|
|
55
|
+
emit('oauth', 'conditional_scopes', { pds: pdsEndpoint, scopes: granted });
|
|
56
|
+
return [base, ...granted].join(' ');
|
|
57
|
+
}
|
|
58
|
+
/** Drop cached describe answers. For tests. */
|
|
59
|
+
export function clearDescribeCache() {
|
|
60
|
+
cache.clear();
|
|
61
|
+
}
|