@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.90
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 +113 -0
- package/dist/backfill.d.ts +29 -2
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +137 -51
- package/dist/car.d.ts +20 -0
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +108 -0
- package/dist/cli.js +247 -1043
- package/dist/config.d.ts +145 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +61 -8
- 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} +68 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +1775 -0
- 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} +122 -34
- 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} +16 -3
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +126 -44
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +127 -0
- package/dist/feeds.d.ts +26 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +72 -16
- package/dist/hooks.d.ts +102 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +186 -0
- package/dist/hydrate.d.ts +49 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +156 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +539 -94
- 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/lex-types.d.ts +2 -0
- package/dist/lex-types.d.ts.map +1 -1
- 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 +198 -75
- package/dist/mst.d.ts +15 -0
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +13 -0
- package/dist/oauth/db.d.ts +16 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +74 -19
- package/dist/oauth/discovery.d.ts +10 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +54 -2
- package/dist/oauth/server.d.ts +93 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +416 -39
- package/dist/oauth/service-describe.d.ts +42 -0
- package/dist/oauth/service-describe.d.ts.map +1 -0
- package/dist/oauth/service-describe.js +108 -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 +94 -42
- 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 +13 -0
- package/dist/private-collections.d.ts.map +1 -0
- package/dist/private-collections.js +27 -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 +34 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1021 -649
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/spaces/blob.d.ts +51 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +111 -0
- package/dist/spaces/credential.d.ts +62 -0
- package/dist/spaces/credential.d.ts.map +1 -0
- package/dist/spaces/credential.js +184 -0
- package/dist/spaces/engine.d.ts +97 -0
- package/dist/spaces/engine.d.ts.map +1 -0
- package/dist/spaces/engine.js +559 -0
- package/dist/spaces/guard.d.ts +41 -0
- package/dist/spaces/guard.d.ts.map +1 -0
- package/dist/spaces/guard.js +79 -0
- package/dist/spaces/identity.d.ts +37 -0
- package/dist/spaces/identity.d.ts.map +1 -0
- package/dist/spaces/identity.js +88 -0
- package/dist/spaces/index.d.ts +55 -0
- package/dist/spaces/index.d.ts.map +1 -0
- package/dist/spaces/index.js +133 -0
- package/dist/spaces/notify.d.ts +51 -0
- package/dist/spaces/notify.d.ts.map +1 -0
- package/dist/spaces/notify.js +121 -0
- package/dist/spaces/records.d.ts +37 -0
- package/dist/spaces/records.d.ts.map +1 -0
- package/dist/spaces/records.js +63 -0
- package/dist/spaces/store.d.ts +51 -0
- package/dist/spaces/store.d.ts.map +1 -0
- package/dist/spaces/store.js +99 -0
- package/dist/spaces/uri.d.ts +60 -0
- package/dist/spaces/uri.d.ts.map +1 -0
- package/dist/spaces/uri.js +97 -0
- package/dist/spaces/verify.d.ts +39 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +87 -0
- package/dist/spaces/viewer.d.ts +51 -0
- package/dist/spaces/viewer.d.ts.map +1 -0
- package/dist/spaces/viewer.js +167 -0
- package/dist/spaces/visibility.d.ts +57 -0
- package/dist/spaces/visibility.d.ts.map +1 -0
- package/dist/spaces/visibility.js +81 -0
- package/dist/templates/feed.tpl +18 -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 +53 -0
- package/dist/test.d.ts +3 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +60 -36
- 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 +101 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +255 -55
- package/package.json +21 -9
- package/public/admin-auth.js +271 -306
- package/public/admin.html +177 -61
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1361
- 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;
|
|
@@ -34,10 +58,58 @@ function pdsClientId(issuer, config) {
|
|
|
34
58
|
}
|
|
35
59
|
return `${issuer}/oauth-client-metadata.json`;
|
|
36
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The endpoints a remote auth server advertises, taken from its metadata rather
|
|
63
|
+
* than rebuilt from its issuer.
|
|
64
|
+
*
|
|
65
|
+
* RFC 8414 requires `authorization_endpoint` and `token_endpoint` and the
|
|
66
|
+
* atproto OAuth profile requires PAR on top of that, so an auth server is free
|
|
67
|
+
* to serve any of them from a path of its choosing. Concatenating `/oauth/...`
|
|
68
|
+
* onto the issuer only ever worked because bsky.social and the reference PDS
|
|
69
|
+
* happen to use those paths.
|
|
70
|
+
*
|
|
71
|
+
* PAR keeps its legacy fallback: it was already read from metadata, and the
|
|
72
|
+
* fallback is what deployments without it have been relying on.
|
|
73
|
+
*/
|
|
74
|
+
function authServerEndpoints(metadata, authServer) {
|
|
75
|
+
if (!metadata.authorization_endpoint) {
|
|
76
|
+
throw new Error(`Auth server ${authServer} advertises no authorization_endpoint`);
|
|
77
|
+
}
|
|
78
|
+
if (!metadata.token_endpoint) {
|
|
79
|
+
throw new Error(`Auth server ${authServer} advertises no token_endpoint`);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
authorizationEndpoint: metadata.authorization_endpoint,
|
|
83
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
84
|
+
parEndpoint: metadata.pushed_authorization_request_endpoint || `${authServer}/oauth/par`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Endpoint stored on a request or session row, falling back to the path this
|
|
89
|
+
* used to hardcode. The fallback is for rows written before those columns
|
|
90
|
+
* existed, not for fresh discovery — a login started after the upgrade always
|
|
91
|
+
* has the advertised endpoint.
|
|
92
|
+
*/
|
|
93
|
+
function storedEndpoint(stored, authServer, legacyPath) {
|
|
94
|
+
return stored || `${authServer}${legacyPath}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Append query parameters to an endpoint URL. RFC 6749 §3.1 lets an
|
|
98
|
+
* authorization endpoint carry its own query component, which a bare
|
|
99
|
+
* `${endpoint}?${params}` would corrupt.
|
|
100
|
+
*/
|
|
101
|
+
function withQuery(endpoint, params) {
|
|
102
|
+
const url = new URL(endpoint);
|
|
103
|
+
for (const [key, value] of params)
|
|
104
|
+
url.searchParams.set(key, value);
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
37
107
|
let serverPrivateJwk;
|
|
38
108
|
let serverPublicJwk;
|
|
39
109
|
let serverPrivateKey;
|
|
40
110
|
let serverJkt;
|
|
111
|
+
let clientPublicJwk;
|
|
112
|
+
let clientPrivateKey;
|
|
41
113
|
let _plcUrl;
|
|
42
114
|
let _relayUrl;
|
|
43
115
|
export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
@@ -57,6 +129,26 @@ export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
|
57
129
|
}
|
|
58
130
|
serverPrivateKey = await importPrivateKey(serverPrivateJwk);
|
|
59
131
|
serverJkt = await computeJwkThumbprint(serverPublicJwk);
|
|
132
|
+
// Client-authentication key, kept separate from the access-token signing key
|
|
133
|
+
// above. A PDS binds each session to the kid that authenticated it and
|
|
134
|
+
// refuses a later refresh signed by a different one, so this key can't be
|
|
135
|
+
// rotated without signing every user out — keeping it distinct means the
|
|
136
|
+
// access-token key stays rotatable.
|
|
137
|
+
const existingClientKey = await getServerKey(CLIENT_KEY_KID);
|
|
138
|
+
let clientPrivateJwk;
|
|
139
|
+
if (existingClientKey) {
|
|
140
|
+
clientPrivateJwk = JSON.parse(existingClientKey.privateKey);
|
|
141
|
+
clientPublicJwk = JSON.parse(existingClientKey.publicKey);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const kp = await generateKeyPair();
|
|
145
|
+
clientPrivateJwk = kp.privateJwk;
|
|
146
|
+
clientPublicJwk = kp.publicJwk;
|
|
147
|
+
await storeServerKey(CLIENT_KEY_KID, JSON.stringify(clientPrivateJwk), JSON.stringify(clientPublicJwk));
|
|
148
|
+
}
|
|
149
|
+
clientPrivateKey = await importPrivateKey(clientPrivateJwk);
|
|
150
|
+
// Initialize SSR session cookie signing
|
|
151
|
+
initSession(serverPrivateJwk, _config.cookieName);
|
|
60
152
|
// Periodic cleanup of expired OAuth data
|
|
61
153
|
setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
|
|
62
154
|
}
|
|
@@ -103,22 +195,96 @@ export function getJwks() {
|
|
|
103
195
|
],
|
|
104
196
|
};
|
|
105
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Whether we authenticate to PDSes as a confidential client.
|
|
200
|
+
*
|
|
201
|
+
* A confidential client gets a 2-year session and 3-month refresh window
|
|
202
|
+
* instead of 2 weeks, and is exempt from the consent screen atproto forces on
|
|
203
|
+
* every authorization request from a public client. It requires a fetchable
|
|
204
|
+
* `jwks_uri`, so loopback deployments (local dev) stay public.
|
|
205
|
+
*/
|
|
206
|
+
export function isConfidentialClient(issuer) {
|
|
207
|
+
return !isLoopbackClient(issuer);
|
|
208
|
+
}
|
|
209
|
+
/** Public half of the client-authentication key, for the PDS to verify assertions. */
|
|
210
|
+
export function getClientJwks() {
|
|
211
|
+
return {
|
|
212
|
+
keys: [
|
|
213
|
+
{
|
|
214
|
+
...clientPublicJwk,
|
|
215
|
+
kid: CLIENT_KEY_KID,
|
|
216
|
+
use: 'sig',
|
|
217
|
+
alg: 'ES256',
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A JWT proving we are this client, per RFC 7523. The PDS checks `sub` against
|
|
224
|
+
* the client_id it resolved and `aud` against its own issuer, then verifies the
|
|
225
|
+
* signature against the key it fetched from `jwks_uri`.
|
|
226
|
+
*/
|
|
227
|
+
async function createClientAssertion(issuer, config, audience) {
|
|
228
|
+
const clientId = pdsClientId(issuer, config);
|
|
229
|
+
const now = Math.floor(Date.now() / 1000);
|
|
230
|
+
return signJwt({ typ: 'JWT', alg: 'ES256', kid: CLIENT_KEY_KID }, {
|
|
231
|
+
iss: clientId,
|
|
232
|
+
sub: clientId,
|
|
233
|
+
aud: audience,
|
|
234
|
+
jti: randomToken(),
|
|
235
|
+
iat: now,
|
|
236
|
+
exp: now + 60,
|
|
237
|
+
}, clientPrivateKey);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Add client authentication to a PAR or token request body. No-op for loopback
|
|
241
|
+
* deployments, which stay public clients.
|
|
242
|
+
*/
|
|
243
|
+
async function withClientAuth(params, issuer, config, audience) {
|
|
244
|
+
if (!isConfidentialClient(issuer))
|
|
245
|
+
return params;
|
|
246
|
+
return {
|
|
247
|
+
...params,
|
|
248
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
249
|
+
client_assertion: await createClientAssertion(issuer, config, audience),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
106
252
|
export function getClientMetadata(issuer, config) {
|
|
107
253
|
// Find the metadata client entry to get its scope
|
|
108
254
|
const metadataClientId = `${issuer}/oauth-client-metadata.json`;
|
|
109
255
|
const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
|
|
256
|
+
const confidential = isConfidentialClient(issuer);
|
|
110
257
|
return {
|
|
111
258
|
client_id: metadataClientId,
|
|
112
259
|
client_name: clientConfig?.client_name || 'hatk',
|
|
113
260
|
redirect_uris: [`${issuer}/oauth/callback`],
|
|
114
261
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
115
262
|
response_types: ['code'],
|
|
116
|
-
|
|
263
|
+
// atproto requires the signing alg alongside private_key_jwt, and requires
|
|
264
|
+
// its absence for a public client.
|
|
265
|
+
...(confidential
|
|
266
|
+
? {
|
|
267
|
+
token_endpoint_auth_method: 'private_key_jwt',
|
|
268
|
+
token_endpoint_auth_signing_alg: 'ES256',
|
|
269
|
+
jwks_uri: `${issuer}/oauth/client-jwks.json`,
|
|
270
|
+
}
|
|
271
|
+
: { token_endpoint_auth_method: 'none' }),
|
|
117
272
|
dpop_bound_access_tokens: true,
|
|
118
273
|
scope: clientConfig?.scope || 'atproto transition:generic',
|
|
119
274
|
};
|
|
120
275
|
}
|
|
121
276
|
// --- PAR Endpoint ---
|
|
277
|
+
/**
|
|
278
|
+
* Handle a Pushed Authorization Request (PAR).
|
|
279
|
+
*
|
|
280
|
+
* Supports account creation via `prompt=create`. When set, `login_hint`
|
|
281
|
+
* is treated as a PDS hostname (e.g. "selfhosted.social" or "localhost:2583")
|
|
282
|
+
* rather than a handle or DID. The auth server is discovered from the PDS's
|
|
283
|
+
* protected resource metadata, and `prompt=create` is forwarded to the PDS
|
|
284
|
+
* PAR so it shows the signup page.
|
|
285
|
+
*
|
|
286
|
+
* For normal login, `login_hint` is a handle or DID as usual.
|
|
287
|
+
*/
|
|
122
288
|
export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
123
289
|
// Validate client DPoP proof
|
|
124
290
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
@@ -143,36 +309,80 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
143
309
|
throw new Error('code_challenge is required');
|
|
144
310
|
if (body.code_challenge_method && body.code_challenge_method !== 'S256')
|
|
145
311
|
throw new Error('Only S256 supported');
|
|
146
|
-
// Resolve DID from login_hint
|
|
312
|
+
// Resolve DID and PDS from login_hint
|
|
313
|
+
const prompt = body.prompt;
|
|
147
314
|
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
315
|
let pdsRequestUri;
|
|
153
316
|
let pdsAuthServer;
|
|
317
|
+
let pdsAuthorizationEndpoint;
|
|
318
|
+
let pdsTokenEndpoint;
|
|
154
319
|
let pdsCodeVerifier;
|
|
155
320
|
let pdsState;
|
|
156
|
-
|
|
321
|
+
let pdsEndpoint;
|
|
322
|
+
if (prompt === 'create' && body.login_hint) {
|
|
323
|
+
// Account creation: login_hint is a PDS URL, discover auth server from it directly
|
|
324
|
+
let pdsUrl;
|
|
325
|
+
if (body.login_hint.startsWith('http')) {
|
|
326
|
+
pdsUrl = body.login_hint;
|
|
327
|
+
}
|
|
328
|
+
else if (body.login_hint.match(/^localhost[:/]/)) {
|
|
329
|
+
pdsUrl = `http://${body.login_hint}`;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
pdsUrl = `https://${body.login_hint}`;
|
|
333
|
+
}
|
|
334
|
+
pdsEndpoint = pdsUrl;
|
|
335
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
336
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
337
|
+
if (!pdsAuthServer)
|
|
338
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
339
|
+
did = undefined; // no DID yet for account creation
|
|
340
|
+
}
|
|
341
|
+
else if (did && !did.startsWith('did:')) {
|
|
342
|
+
try {
|
|
343
|
+
did = await resolveHandle(did, _relayUrl);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
throw new Error('Handle not found');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Discover user's PDS auth server (for login flow with a resolved DID)
|
|
350
|
+
if (did && !pdsAuthServer) {
|
|
157
351
|
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
158
352
|
pdsAuthServer = discovery.authServerEndpoint;
|
|
353
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
354
|
+
}
|
|
355
|
+
if (pdsAuthServer) {
|
|
356
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
357
|
+
const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
|
|
358
|
+
// Held for the authorize redirect and the token exchange, which run on
|
|
359
|
+
// later requests with only this row to go on.
|
|
360
|
+
pdsAuthorizationEndpoint = endpoints.authorizationEndpoint;
|
|
361
|
+
pdsTokenEndpoint = endpoints.tokenEndpoint;
|
|
159
362
|
// Create PKCE for our PAR to the PDS
|
|
160
363
|
pdsCodeVerifier = randomToken();
|
|
161
364
|
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
162
365
|
pdsState = randomToken(); // unique state to correlate callback
|
|
163
366
|
// PAR to the PDS
|
|
164
|
-
const parEndpoint =
|
|
367
|
+
const parEndpoint = endpoints.parEndpoint;
|
|
165
368
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
166
|
-
const
|
|
369
|
+
const pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
370
|
+
const pdsParParams = {
|
|
167
371
|
client_id: pdsClientId(config.issuer, config),
|
|
168
372
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
169
373
|
response_type: 'code',
|
|
170
374
|
code_challenge: pdsCodeChallenge,
|
|
171
375
|
code_challenge_method: 'S256',
|
|
172
|
-
scope:
|
|
173
|
-
login_hint: body.login_hint || did,
|
|
376
|
+
scope: pdsScope,
|
|
174
377
|
state: pdsState,
|
|
175
|
-
}
|
|
378
|
+
};
|
|
379
|
+
if (prompt === 'create') {
|
|
380
|
+
pdsParParams.prompt = 'create';
|
|
381
|
+
}
|
|
382
|
+
if (did) {
|
|
383
|
+
pdsParParams.login_hint = body.login_hint || did;
|
|
384
|
+
}
|
|
385
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
176
386
|
const pdsParRes = await fetch(parEndpoint, {
|
|
177
387
|
method: 'POST',
|
|
178
388
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -234,6 +444,9 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
234
444
|
dpopJkt: dpop.jkt,
|
|
235
445
|
pdsRequestUri,
|
|
236
446
|
pdsAuthServer,
|
|
447
|
+
pdsAuthorizationEndpoint,
|
|
448
|
+
pdsTokenEndpoint,
|
|
449
|
+
pdsEndpoint,
|
|
237
450
|
pdsCodeVerifier,
|
|
238
451
|
pdsState,
|
|
239
452
|
did,
|
|
@@ -251,12 +464,135 @@ export function buildAuthorizeRedirect(config, request) {
|
|
|
251
464
|
request_uri: request.pds_request_uri,
|
|
252
465
|
client_id: pdsClientId(config.issuer, config),
|
|
253
466
|
});
|
|
254
|
-
return
|
|
467
|
+
return withQuery(storedEndpoint(request.pds_authorization_endpoint, request.pds_auth_server, '/oauth/authorize'), params);
|
|
468
|
+
}
|
|
469
|
+
// --- Server-initiated login (no DPoP required from browser) ---
|
|
470
|
+
/**
|
|
471
|
+
* Initiate a server-side OAuth login or account creation flow.
|
|
472
|
+
*
|
|
473
|
+
* For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
|
|
474
|
+
* The `pds` is a bare hostname; the auth server is discovered from its
|
|
475
|
+
* protected resource metadata.
|
|
476
|
+
*/
|
|
477
|
+
export async function serverLogin(config, handle, options) {
|
|
478
|
+
let did;
|
|
479
|
+
let pdsAuthServer;
|
|
480
|
+
let pdsEndpoint;
|
|
481
|
+
if (options?.prompt === 'create' && options?.pds) {
|
|
482
|
+
// Account creation: discover auth server from PDS hostname
|
|
483
|
+
const pdsUrl = options.pds.startsWith('http')
|
|
484
|
+
? options.pds
|
|
485
|
+
: options.pds.match(/^localhost[:/]/)
|
|
486
|
+
? `http://${options.pds}`
|
|
487
|
+
: `https://${options.pds}`;
|
|
488
|
+
pdsEndpoint = pdsUrl;
|
|
489
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
490
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
491
|
+
if (!pdsAuthServer)
|
|
492
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Normal login: resolve handle to DID
|
|
496
|
+
did = handle;
|
|
497
|
+
if (!did.startsWith('did:')) {
|
|
498
|
+
did = await resolveHandle(handle, _relayUrl);
|
|
499
|
+
}
|
|
500
|
+
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
501
|
+
pdsAuthServer = discovery.authServerEndpoint;
|
|
502
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
503
|
+
}
|
|
504
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
505
|
+
const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
|
|
506
|
+
// Create PKCE for PAR to PDS
|
|
507
|
+
const pdsCodeVerifier = randomToken();
|
|
508
|
+
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
509
|
+
const pdsState = randomToken();
|
|
510
|
+
// PAR to the PDS
|
|
511
|
+
const parEndpoint = endpoints.parEndpoint;
|
|
512
|
+
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
513
|
+
const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
514
|
+
const pdsParParams = {
|
|
515
|
+
client_id: pdsClientId(config.issuer, config),
|
|
516
|
+
redirect_uri: pdsRedirectUri(config.issuer),
|
|
517
|
+
response_type: 'code',
|
|
518
|
+
code_challenge: pdsCodeChallenge,
|
|
519
|
+
code_challenge_method: 'S256',
|
|
520
|
+
scope,
|
|
521
|
+
state: pdsState,
|
|
522
|
+
};
|
|
523
|
+
if (options?.prompt === 'create') {
|
|
524
|
+
pdsParParams.prompt = 'create';
|
|
525
|
+
}
|
|
526
|
+
if (did) {
|
|
527
|
+
pdsParParams.login_hint = handle;
|
|
528
|
+
}
|
|
529
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
530
|
+
let pdsRequestUri;
|
|
531
|
+
const pdsParRes = await fetch(parEndpoint, {
|
|
532
|
+
method: 'POST',
|
|
533
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
534
|
+
body: pdsParBody.toString(),
|
|
535
|
+
});
|
|
536
|
+
if (!pdsParRes.ok) {
|
|
537
|
+
const errBody = await pdsParRes.json().catch(() => ({}));
|
|
538
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
539
|
+
const nonce = pdsParRes.headers.get('DPoP-Nonce');
|
|
540
|
+
if (nonce) {
|
|
541
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
|
|
542
|
+
const retryRes = await fetch(parEndpoint, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
545
|
+
body: pdsParBody.toString(),
|
|
546
|
+
});
|
|
547
|
+
if (!retryRes.ok) {
|
|
548
|
+
const retryErr = await retryRes.json().catch(() => ({}));
|
|
549
|
+
throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
|
|
550
|
+
}
|
|
551
|
+
const retryData = await retryRes.json();
|
|
552
|
+
pdsRequestUri = retryData.request_uri;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const pdsParData = await pdsParRes.json();
|
|
561
|
+
pdsRequestUri = pdsParData.request_uri;
|
|
562
|
+
}
|
|
563
|
+
// Store the request so the callback can find it
|
|
564
|
+
const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
|
|
565
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 600;
|
|
566
|
+
await storeOAuthRequest(requestUri, {
|
|
567
|
+
clientId: pdsClientId(config.issuer, config),
|
|
568
|
+
redirectUri: '/',
|
|
569
|
+
scope,
|
|
570
|
+
state: pdsState,
|
|
571
|
+
codeChallenge: '',
|
|
572
|
+
codeChallengeMethod: 'S256',
|
|
573
|
+
dpopJkt: serverJkt,
|
|
574
|
+
pdsRequestUri,
|
|
575
|
+
pdsAuthServer,
|
|
576
|
+
pdsAuthorizationEndpoint: endpoints.authorizationEndpoint,
|
|
577
|
+
pdsTokenEndpoint: endpoints.tokenEndpoint,
|
|
578
|
+
pdsEndpoint,
|
|
579
|
+
pdsCodeVerifier,
|
|
580
|
+
pdsState,
|
|
581
|
+
did,
|
|
582
|
+
loginHint: handle,
|
|
583
|
+
expiresAt,
|
|
584
|
+
});
|
|
585
|
+
// Build redirect URL to PDS
|
|
586
|
+
const params = new URLSearchParams({
|
|
587
|
+
request_uri: pdsRequestUri,
|
|
588
|
+
client_id: pdsClientId(config.issuer, config),
|
|
589
|
+
});
|
|
590
|
+
return withQuery(endpoints.authorizationEndpoint, params);
|
|
255
591
|
}
|
|
256
592
|
// --- OAuth Callback (PDS redirects here) ---
|
|
257
593
|
export async function handleCallback(config, code, state, iss) {
|
|
258
594
|
// Find the matching OAuth request by pds_state (unique per PAR)
|
|
259
|
-
const { querySQL } = await import("../db.js");
|
|
595
|
+
const { querySQL } = await import("../database/db.js");
|
|
260
596
|
let request = null;
|
|
261
597
|
if (state) {
|
|
262
598
|
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
|
|
@@ -273,15 +609,15 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
273
609
|
if (!request)
|
|
274
610
|
throw new Error('No matching authorization request found');
|
|
275
611
|
// Exchange code at PDS token endpoint
|
|
276
|
-
const tokenEndpoint =
|
|
612
|
+
const tokenEndpoint = storedEndpoint(request.pds_token_endpoint, request.pds_auth_server, '/oauth/token');
|
|
277
613
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
278
|
-
const tokenBody = new URLSearchParams({
|
|
614
|
+
const tokenBody = new URLSearchParams(await withClientAuth({
|
|
279
615
|
grant_type: 'authorization_code',
|
|
280
616
|
code,
|
|
281
617
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
282
618
|
client_id: pdsClientId(config.issuer, config),
|
|
283
619
|
code_verifier: request.pds_code_verifier,
|
|
284
|
-
});
|
|
620
|
+
}, config.issuer, config, request.pds_auth_server));
|
|
285
621
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
286
622
|
method: 'POST',
|
|
287
623
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -329,42 +665,76 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
329
665
|
const did = tokenData.sub;
|
|
330
666
|
if (!did)
|
|
331
667
|
throw new Error('PDS token response missing sub (DID)');
|
|
332
|
-
// Store PDS session server-side
|
|
668
|
+
// Store PDS session server-side — pds_endpoint is the actual data PDS
|
|
669
|
+
// (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
|
|
333
670
|
await storeSession(did, {
|
|
334
|
-
pdsEndpoint: request.
|
|
671
|
+
pdsEndpoint: request.pds_endpoint,
|
|
672
|
+
pdsAuthServer: request.pds_auth_server,
|
|
673
|
+
pdsTokenEndpoint: tokenEndpoint,
|
|
335
674
|
accessToken: tokenData.access_token,
|
|
336
675
|
refreshToken: tokenData.refresh_token,
|
|
337
676
|
dpopJkt: serverJkt,
|
|
338
677
|
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
339
678
|
});
|
|
340
|
-
await fireOnLoginHook(did);
|
|
679
|
+
await fireOnLoginHook(did, config);
|
|
341
680
|
// Generate authorization code for the client
|
|
342
681
|
const clientCode = randomToken();
|
|
343
682
|
await storeAuthCode(clientCode, request.request_uri);
|
|
344
683
|
// Update the request with the DID (in case it wasn't set during PAR)
|
|
345
684
|
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);
|
|
685
|
+
const { runSQL } = await import("../database/db.js");
|
|
686
|
+
await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
|
|
348
687
|
}
|
|
349
688
|
// Build redirect back to client
|
|
350
689
|
const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
|
|
351
690
|
if (request.state)
|
|
352
691
|
params.set('state', request.state);
|
|
353
692
|
const clientRedirectUri = `${request.redirect_uri}?${params}`;
|
|
354
|
-
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
|
|
693
|
+
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Handle an error redirect from the PDS (user denied, request expired, etc).
|
|
697
|
+
*
|
|
698
|
+
* The PDS reports failures by redirecting to our callback with `error` and no
|
|
699
|
+
* `code`. Discard the pending request and return a URL that hands the error
|
|
700
|
+
* back to the app, so the browser lands somewhere renderable instead of on a
|
|
701
|
+
* raw error response.
|
|
702
|
+
*/
|
|
703
|
+
export async function handleCallbackError(config, error, errorDescription, state) {
|
|
704
|
+
let request = null;
|
|
705
|
+
if (state) {
|
|
706
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
|
|
707
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
708
|
+
}
|
|
709
|
+
if (request)
|
|
710
|
+
await deleteOAuthRequest(request.request_uri);
|
|
711
|
+
emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
|
|
712
|
+
const params = new URLSearchParams({ error });
|
|
713
|
+
if (errorDescription)
|
|
714
|
+
params.set('error_description', errorDescription);
|
|
715
|
+
// Server-initiated logins store redirect_uri as '/' — send the browser home
|
|
716
|
+
// with the error attached. Same for an unmatched state (expired or replayed).
|
|
717
|
+
if (!request || request.redirect_uri === '/')
|
|
718
|
+
return `/?${params}`;
|
|
719
|
+
// Client flow: echo the client's state and our issuer so the SPA's callback
|
|
720
|
+
// handler recognises the redirect as its own rather than re-entering here.
|
|
721
|
+
if (request.state)
|
|
722
|
+
params.set('state', request.state);
|
|
723
|
+
params.set('iss', config.issuer);
|
|
724
|
+
return `${request.redirect_uri}?${params}`;
|
|
355
725
|
}
|
|
356
726
|
// --- Token Endpoint ---
|
|
357
727
|
export async function handleToken(config, body, dpopHeader, requestUrl) {
|
|
358
728
|
const grantType = body.grant_type;
|
|
359
729
|
if (!grantType)
|
|
360
|
-
throw new
|
|
730
|
+
throw new OAuthError('invalid_request', 'grant_type is required');
|
|
361
731
|
if (grantType === 'authorization_code') {
|
|
362
732
|
return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
|
|
363
733
|
}
|
|
364
734
|
else if (grantType === 'refresh_token') {
|
|
365
735
|
return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
|
|
366
736
|
}
|
|
367
|
-
throw new
|
|
737
|
+
throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
|
|
368
738
|
}
|
|
369
739
|
async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
|
|
370
740
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
@@ -437,20 +807,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
437
807
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
438
808
|
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
439
809
|
if (!fresh)
|
|
440
|
-
throw new
|
|
810
|
+
throw new OAuthError('invalid_request', 'DPoP jti replay detected');
|
|
441
811
|
const { refresh_token, client_id } = body;
|
|
442
812
|
if (!refresh_token || !client_id)
|
|
443
|
-
throw new
|
|
444
|
-
// Look up and validate refresh token
|
|
813
|
+
throw new OAuthError('invalid_request', 'Missing required parameters');
|
|
814
|
+
// Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
|
|
815
|
+
// `invalid_grant` — the client must treat them as terminal and re-authenticate.
|
|
445
816
|
const stored = await getRefreshToken(refresh_token);
|
|
446
817
|
if (!stored)
|
|
447
|
-
throw new
|
|
818
|
+
throw new OAuthError('invalid_grant', 'Invalid refresh token');
|
|
448
819
|
if (stored.revoked)
|
|
449
|
-
throw new
|
|
820
|
+
throw new OAuthError('invalid_grant', 'Refresh token revoked');
|
|
450
821
|
if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
|
|
451
|
-
throw new
|
|
822
|
+
throw new OAuthError('invalid_grant', 'Refresh token expired');
|
|
452
823
|
if (stored.client_id !== client_id)
|
|
453
|
-
throw new
|
|
824
|
+
throw new OAuthError('invalid_grant', 'client_id mismatch');
|
|
454
825
|
// Revoke old refresh token (rotation)
|
|
455
826
|
await revokeRefreshToken(refresh_token);
|
|
456
827
|
const did = stored.did;
|
|
@@ -492,14 +863,17 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
492
863
|
export async function refreshPdsSession(config, session) {
|
|
493
864
|
if (!session.refresh_token)
|
|
494
865
|
return null;
|
|
495
|
-
|
|
866
|
+
// Prefer the endpoint the auth server advertised when this session was
|
|
867
|
+
// authorized. Sessions predating that column fall back to the auth server
|
|
868
|
+
// (or, older still, the PDS) with the path this used to hardcode.
|
|
869
|
+
const tokenEndpoint = storedEndpoint(session.pds_token_endpoint, session.pds_auth_server || session.pds_endpoint, '/oauth/token');
|
|
496
870
|
const clientId = pdsClientId(config.issuer, config);
|
|
497
871
|
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
498
|
-
const body = new URLSearchParams({
|
|
872
|
+
const body = new URLSearchParams(await withClientAuth({
|
|
499
873
|
grant_type: 'refresh_token',
|
|
500
874
|
refresh_token: session.refresh_token,
|
|
501
875
|
client_id: clientId,
|
|
502
|
-
});
|
|
876
|
+
}, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
|
|
503
877
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
504
878
|
method: 'POST',
|
|
505
879
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
|
|
@@ -526,12 +900,15 @@ export async function refreshPdsSession(config, session) {
|
|
|
526
900
|
did: session.did,
|
|
527
901
|
pds_endpoint: session.pds_endpoint,
|
|
528
902
|
});
|
|
903
|
+
await deleteSession(session.did);
|
|
529
904
|
return null;
|
|
530
905
|
}
|
|
531
906
|
const tokenData = await tokenRes.json();
|
|
532
907
|
// Update stored session
|
|
533
908
|
await storeSession(session.did, {
|
|
534
909
|
pdsEndpoint: session.pds_endpoint,
|
|
910
|
+
pdsAuthServer: session.pds_auth_server,
|
|
911
|
+
pdsTokenEndpoint: session.pds_token_endpoint,
|
|
535
912
|
accessToken: tokenData.access_token,
|
|
536
913
|
refreshToken: tokenData.refresh_token || session.refresh_token,
|
|
537
914
|
dpopJkt: session.dpop_jkt,
|
|
@@ -0,0 +1,42 @@
|
|
|
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 direct question and the answer to trust when
|
|
6
|
+
* it comes — but it is still a proposal, and implementations exist that serve
|
|
7
|
+
* the features while serving no describe. The reference spaces build of
|
|
8
|
+
* `@atproto/pds` is one: it routes every space method and does not contain the
|
|
9
|
+
* describe NSID at all. Reading its silence as "no optional features" denies it
|
|
10
|
+
* scopes it could honor.
|
|
11
|
+
*
|
|
12
|
+
* So when describe says nothing, the methods a caller actually cares about are
|
|
13
|
+
* probed directly. Calling one and reading the failure cannot tell a missing
|
|
14
|
+
* feature from a broken server *on its own* — but it can against a control: ask
|
|
15
|
+
* for an NSID that certainly does not exist, learn how this server says "no such
|
|
16
|
+
* method", and a different answer for a real method is evidence it has one.
|
|
17
|
+
*
|
|
18
|
+
* Used before pushing an authorization request, so a PDS is asked for a
|
|
19
|
+
* feature's scopes only when it implements the feature.
|
|
20
|
+
*/
|
|
21
|
+
import type { OAuthConfig } from '../config.ts';
|
|
22
|
+
/**
|
|
23
|
+
* Every XRPC method a PDS serves, as far as it can be established.
|
|
24
|
+
*
|
|
25
|
+
* `candidates` are the methods the caller needs an answer about. They are only
|
|
26
|
+
* used when describe says nothing — an explicit list is always preferred to
|
|
27
|
+
* inference, and a server that answers describe is taken at its word.
|
|
28
|
+
*/
|
|
29
|
+
export declare function describeMethods(pdsEndpoint: string, candidates?: string[]): Promise<Set<string>>;
|
|
30
|
+
/**
|
|
31
|
+
* The scope string to request from this PDS: `base`, plus any conditional set
|
|
32
|
+
* whose `whenMethod` the server serves.
|
|
33
|
+
*
|
|
34
|
+
* Loopback clients are left alone. Their client_id encodes the scope they may
|
|
35
|
+
* request, and the token exchange rebuilds that client_id from config — vary
|
|
36
|
+
* the request and the two disagree. A local setup that needs the extra scopes
|
|
37
|
+
* lists them in its client's own `scope` instead.
|
|
38
|
+
*/
|
|
39
|
+
export declare function negotiateScope(config: OAuthConfig, base: string, pdsEndpoint: string | undefined, isLoopback: boolean): Promise<string>;
|
|
40
|
+
/** Drop cached describe answers. For tests. */
|
|
41
|
+
export declare function clearDescribeCache(): void;
|
|
42
|
+
//# sourceMappingURL=service-describe.d.ts.map
|