@hatk/hatk 0.0.1-alpha.1 → 0.0.1-alpha.100
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 +90 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +254 -55
- package/dist/car.d.ts +59 -1
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +179 -7
- package/dist/cbor.d.ts +37 -0
- package/dist/cbor.d.ts.map +1 -1
- package/dist/cbor.js +36 -3
- package/dist/cid.d.ts +37 -0
- package/dist/cid.d.ts.map +1 -1
- package/dist/cid.js +38 -3
- package/dist/cli.js +247 -986
- package/dist/config.d.ts +145 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +62 -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} +94 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/database/db.js +1833 -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/database/schema.d.ts +91 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +153 -54
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +126 -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 +55 -6
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +21 -20
- package/dist/indexer.d.ts +195 -3
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +634 -94
- package/dist/jetstream.d.ts +118 -0
- package/dist/jetstream.d.ts.map +1 -0
- package/dist/jetstream.js +238 -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 +197 -75
- 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 +27 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +80 -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 +131 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +504 -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 +92 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +396 -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 +1053 -626
- 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 +112 -0
- package/dist/spaces/blob.d.ts.map +1 -0
- package/dist/spaces/blob.js +460 -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 +46 -0
- package/dist/spaces/verify.d.ts.map +1 -0
- package/dist/spaces/verify.js +94 -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 -65
- package/dist/xrpc.d.ts +122 -12
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +270 -55
- package/package.json +34 -10
- package/public/admin-auth.js +271 -306
- package/public/admin.html +177 -61
- package/public/robots.txt +2 -0
- 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 +0 -51
- 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,81 @@ 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 pdsScope;
|
|
322
|
+
let pdsEndpoint;
|
|
323
|
+
if (prompt === 'create' && body.login_hint) {
|
|
324
|
+
// Account creation: login_hint is a PDS URL, discover auth server from it directly
|
|
325
|
+
let pdsUrl;
|
|
326
|
+
if (body.login_hint.startsWith('http')) {
|
|
327
|
+
pdsUrl = body.login_hint;
|
|
328
|
+
}
|
|
329
|
+
else if (body.login_hint.match(/^localhost[:/]/)) {
|
|
330
|
+
pdsUrl = `http://${body.login_hint}`;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
pdsUrl = `https://${body.login_hint}`;
|
|
334
|
+
}
|
|
335
|
+
pdsEndpoint = pdsUrl;
|
|
336
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
337
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
338
|
+
if (!pdsAuthServer)
|
|
339
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
340
|
+
did = undefined; // no DID yet for account creation
|
|
341
|
+
}
|
|
342
|
+
else if (did && !did.startsWith('did:')) {
|
|
343
|
+
try {
|
|
344
|
+
did = await resolveHandle(did, _relayUrl);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
throw new Error('Handle not found');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Discover user's PDS auth server (for login flow with a resolved DID)
|
|
351
|
+
if (did && !pdsAuthServer) {
|
|
157
352
|
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
158
353
|
pdsAuthServer = discovery.authServerEndpoint;
|
|
354
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
355
|
+
}
|
|
356
|
+
if (pdsAuthServer) {
|
|
357
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
358
|
+
const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
|
|
359
|
+
// Held for the authorize redirect and the token exchange, which run on
|
|
360
|
+
// later requests with only this row to go on.
|
|
361
|
+
pdsAuthorizationEndpoint = endpoints.authorizationEndpoint;
|
|
362
|
+
pdsTokenEndpoint = endpoints.tokenEndpoint;
|
|
159
363
|
// Create PKCE for our PAR to the PDS
|
|
160
364
|
pdsCodeVerifier = randomToken();
|
|
161
365
|
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
162
366
|
pdsState = randomToken(); // unique state to correlate callback
|
|
163
367
|
// PAR to the PDS
|
|
164
|
-
const parEndpoint =
|
|
368
|
+
const parEndpoint = endpoints.parEndpoint;
|
|
165
369
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
166
|
-
|
|
370
|
+
pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
371
|
+
const pdsParParams = {
|
|
167
372
|
client_id: pdsClientId(config.issuer, config),
|
|
168
373
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
169
374
|
response_type: 'code',
|
|
170
375
|
code_challenge: pdsCodeChallenge,
|
|
171
376
|
code_challenge_method: 'S256',
|
|
172
|
-
scope:
|
|
173
|
-
login_hint: body.login_hint || did,
|
|
377
|
+
scope: pdsScope,
|
|
174
378
|
state: pdsState,
|
|
175
|
-
}
|
|
379
|
+
};
|
|
380
|
+
if (prompt === 'create') {
|
|
381
|
+
pdsParParams.prompt = 'create';
|
|
382
|
+
}
|
|
383
|
+
if (did) {
|
|
384
|
+
pdsParParams.login_hint = body.login_hint || did;
|
|
385
|
+
}
|
|
386
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
176
387
|
const pdsParRes = await fetch(parEndpoint, {
|
|
177
388
|
method: 'POST',
|
|
178
389
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -234,10 +445,14 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
|
234
445
|
dpopJkt: dpop.jkt,
|
|
235
446
|
pdsRequestUri,
|
|
236
447
|
pdsAuthServer,
|
|
448
|
+
pdsAuthorizationEndpoint,
|
|
449
|
+
pdsTokenEndpoint,
|
|
450
|
+
pdsEndpoint,
|
|
237
451
|
pdsCodeVerifier,
|
|
238
452
|
pdsState,
|
|
239
453
|
did,
|
|
240
454
|
loginHint: body.login_hint,
|
|
455
|
+
pdsScope,
|
|
241
456
|
expiresAt,
|
|
242
457
|
});
|
|
243
458
|
return { request_uri: requestUri, expires_in: 600 };
|
|
@@ -251,12 +466,136 @@ export function buildAuthorizeRedirect(config, request) {
|
|
|
251
466
|
request_uri: request.pds_request_uri,
|
|
252
467
|
client_id: pdsClientId(config.issuer, config),
|
|
253
468
|
});
|
|
254
|
-
return
|
|
469
|
+
return withQuery(storedEndpoint(request.pds_authorization_endpoint, request.pds_auth_server, '/oauth/authorize'), params);
|
|
470
|
+
}
|
|
471
|
+
// --- Server-initiated login (no DPoP required from browser) ---
|
|
472
|
+
/**
|
|
473
|
+
* Initiate a server-side OAuth login or account creation flow.
|
|
474
|
+
*
|
|
475
|
+
* For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
|
|
476
|
+
* The `pds` is a bare hostname; the auth server is discovered from its
|
|
477
|
+
* protected resource metadata.
|
|
478
|
+
*/
|
|
479
|
+
export async function serverLogin(config, handle, options) {
|
|
480
|
+
let did;
|
|
481
|
+
let pdsAuthServer;
|
|
482
|
+
let pdsEndpoint;
|
|
483
|
+
if (options?.prompt === 'create' && options?.pds) {
|
|
484
|
+
// Account creation: discover auth server from PDS hostname
|
|
485
|
+
const pdsUrl = options.pds.startsWith('http')
|
|
486
|
+
? options.pds
|
|
487
|
+
: options.pds.match(/^localhost[:/]/)
|
|
488
|
+
? `http://${options.pds}`
|
|
489
|
+
: `https://${options.pds}`;
|
|
490
|
+
pdsEndpoint = pdsUrl;
|
|
491
|
+
const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
|
|
492
|
+
pdsAuthServer = protectedResource.authorization_servers[0];
|
|
493
|
+
if (!pdsAuthServer)
|
|
494
|
+
throw new Error(`No auth server for PDS ${pdsUrl}`);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
// Normal login: resolve handle to DID
|
|
498
|
+
did = handle;
|
|
499
|
+
if (!did.startsWith('did:')) {
|
|
500
|
+
did = await resolveHandle(handle, _relayUrl);
|
|
501
|
+
}
|
|
502
|
+
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
503
|
+
pdsAuthServer = discovery.authServerEndpoint;
|
|
504
|
+
pdsEndpoint = discovery.pdsEndpoint;
|
|
505
|
+
}
|
|
506
|
+
const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
|
|
507
|
+
const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
|
|
508
|
+
// Create PKCE for PAR to PDS
|
|
509
|
+
const pdsCodeVerifier = randomToken();
|
|
510
|
+
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
511
|
+
const pdsState = randomToken();
|
|
512
|
+
// PAR to the PDS
|
|
513
|
+
const parEndpoint = endpoints.parEndpoint;
|
|
514
|
+
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
515
|
+
const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
|
|
516
|
+
const pdsParParams = {
|
|
517
|
+
client_id: pdsClientId(config.issuer, config),
|
|
518
|
+
redirect_uri: pdsRedirectUri(config.issuer),
|
|
519
|
+
response_type: 'code',
|
|
520
|
+
code_challenge: pdsCodeChallenge,
|
|
521
|
+
code_challenge_method: 'S256',
|
|
522
|
+
scope,
|
|
523
|
+
state: pdsState,
|
|
524
|
+
};
|
|
525
|
+
if (options?.prompt === 'create') {
|
|
526
|
+
pdsParParams.prompt = 'create';
|
|
527
|
+
}
|
|
528
|
+
if (did) {
|
|
529
|
+
pdsParParams.login_hint = handle;
|
|
530
|
+
}
|
|
531
|
+
const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
|
|
532
|
+
let pdsRequestUri;
|
|
533
|
+
const pdsParRes = await fetch(parEndpoint, {
|
|
534
|
+
method: 'POST',
|
|
535
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
536
|
+
body: pdsParBody.toString(),
|
|
537
|
+
});
|
|
538
|
+
if (!pdsParRes.ok) {
|
|
539
|
+
const errBody = await pdsParRes.json().catch(() => ({}));
|
|
540
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
541
|
+
const nonce = pdsParRes.headers.get('DPoP-Nonce');
|
|
542
|
+
if (nonce) {
|
|
543
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
|
|
544
|
+
const retryRes = await fetch(parEndpoint, {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
547
|
+
body: pdsParBody.toString(),
|
|
548
|
+
});
|
|
549
|
+
if (!retryRes.ok) {
|
|
550
|
+
const retryErr = await retryRes.json().catch(() => ({}));
|
|
551
|
+
throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
|
|
552
|
+
}
|
|
553
|
+
const retryData = await retryRes.json();
|
|
554
|
+
pdsRequestUri = retryData.request_uri;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
const pdsParData = await pdsParRes.json();
|
|
563
|
+
pdsRequestUri = pdsParData.request_uri;
|
|
564
|
+
}
|
|
565
|
+
// Store the request so the callback can find it
|
|
566
|
+
const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
|
|
567
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 600;
|
|
568
|
+
await storeOAuthRequest(requestUri, {
|
|
569
|
+
clientId: pdsClientId(config.issuer, config),
|
|
570
|
+
redirectUri: '/',
|
|
571
|
+
scope,
|
|
572
|
+
state: pdsState,
|
|
573
|
+
codeChallenge: '',
|
|
574
|
+
codeChallengeMethod: 'S256',
|
|
575
|
+
dpopJkt: serverJkt,
|
|
576
|
+
pdsRequestUri,
|
|
577
|
+
pdsAuthServer,
|
|
578
|
+
pdsAuthorizationEndpoint: endpoints.authorizationEndpoint,
|
|
579
|
+
pdsTokenEndpoint: endpoints.tokenEndpoint,
|
|
580
|
+
pdsEndpoint,
|
|
581
|
+
pdsCodeVerifier,
|
|
582
|
+
pdsState,
|
|
583
|
+
did,
|
|
584
|
+
loginHint: handle,
|
|
585
|
+
pdsScope: scope,
|
|
586
|
+
expiresAt,
|
|
587
|
+
});
|
|
588
|
+
// Build redirect URL to PDS
|
|
589
|
+
const params = new URLSearchParams({
|
|
590
|
+
request_uri: pdsRequestUri,
|
|
591
|
+
client_id: pdsClientId(config.issuer, config),
|
|
592
|
+
});
|
|
593
|
+
return withQuery(endpoints.authorizationEndpoint, params);
|
|
255
594
|
}
|
|
256
595
|
// --- OAuth Callback (PDS redirects here) ---
|
|
257
596
|
export async function handleCallback(config, code, state, iss) {
|
|
258
597
|
// Find the matching OAuth request by pds_state (unique per PAR)
|
|
259
|
-
const { querySQL } = await import("../db.js");
|
|
598
|
+
const { querySQL } = await import("../database/db.js");
|
|
260
599
|
let request = null;
|
|
261
600
|
if (state) {
|
|
262
601
|
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
|
|
@@ -273,15 +612,15 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
273
612
|
if (!request)
|
|
274
613
|
throw new Error('No matching authorization request found');
|
|
275
614
|
// Exchange code at PDS token endpoint
|
|
276
|
-
const tokenEndpoint =
|
|
615
|
+
const tokenEndpoint = storedEndpoint(request.pds_token_endpoint, request.pds_auth_server, '/oauth/token');
|
|
277
616
|
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
278
|
-
const tokenBody = new URLSearchParams({
|
|
617
|
+
const tokenBody = new URLSearchParams(await withClientAuth({
|
|
279
618
|
grant_type: 'authorization_code',
|
|
280
619
|
code,
|
|
281
620
|
redirect_uri: pdsRedirectUri(config.issuer),
|
|
282
621
|
client_id: pdsClientId(config.issuer, config),
|
|
283
622
|
code_verifier: request.pds_code_verifier,
|
|
284
|
-
});
|
|
623
|
+
}, config.issuer, config, request.pds_auth_server));
|
|
285
624
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
286
625
|
method: 'POST',
|
|
287
626
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
@@ -329,42 +668,78 @@ export async function handleCallback(config, code, state, iss) {
|
|
|
329
668
|
const did = tokenData.sub;
|
|
330
669
|
if (!did)
|
|
331
670
|
throw new Error('PDS token response missing sub (DID)');
|
|
332
|
-
// Store PDS session server-side
|
|
671
|
+
// Store PDS session server-side — pds_endpoint is the actual data PDS
|
|
672
|
+
// (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
|
|
333
673
|
await storeSession(did, {
|
|
334
|
-
pdsEndpoint: request.
|
|
674
|
+
pdsEndpoint: request.pds_endpoint,
|
|
675
|
+
pdsAuthServer: request.pds_auth_server,
|
|
676
|
+
pdsTokenEndpoint: tokenEndpoint,
|
|
335
677
|
accessToken: tokenData.access_token,
|
|
336
678
|
refreshToken: tokenData.refresh_token,
|
|
337
679
|
dpopJkt: serverJkt,
|
|
338
680
|
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
681
|
+
requestedScope: request.pds_scope ?? undefined,
|
|
682
|
+
grantedScope: typeof tokenData.scope === 'string' ? tokenData.scope : undefined,
|
|
339
683
|
});
|
|
340
|
-
await fireOnLoginHook(did);
|
|
684
|
+
await fireOnLoginHook(did, config);
|
|
341
685
|
// Generate authorization code for the client
|
|
342
686
|
const clientCode = randomToken();
|
|
343
687
|
await storeAuthCode(clientCode, request.request_uri);
|
|
344
688
|
// Update the request with the DID (in case it wasn't set during PAR)
|
|
345
689
|
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);
|
|
690
|
+
const { runSQL } = await import("../database/db.js");
|
|
691
|
+
await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
|
|
348
692
|
}
|
|
349
693
|
// Build redirect back to client
|
|
350
694
|
const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
|
|
351
695
|
if (request.state)
|
|
352
696
|
params.set('state', request.state);
|
|
353
697
|
const clientRedirectUri = `${request.redirect_uri}?${params}`;
|
|
354
|
-
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
|
|
698
|
+
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Handle an error redirect from the PDS (user denied, request expired, etc).
|
|
702
|
+
*
|
|
703
|
+
* The PDS reports failures by redirecting to our callback with `error` and no
|
|
704
|
+
* `code`. Discard the pending request and return a URL that hands the error
|
|
705
|
+
* back to the app, so the browser lands somewhere renderable instead of on a
|
|
706
|
+
* raw error response.
|
|
707
|
+
*/
|
|
708
|
+
export async function handleCallbackError(config, error, errorDescription, state) {
|
|
709
|
+
let request = null;
|
|
710
|
+
if (state) {
|
|
711
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
|
|
712
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
713
|
+
}
|
|
714
|
+
if (request)
|
|
715
|
+
await deleteOAuthRequest(request.request_uri);
|
|
716
|
+
emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
|
|
717
|
+
const params = new URLSearchParams({ error });
|
|
718
|
+
if (errorDescription)
|
|
719
|
+
params.set('error_description', errorDescription);
|
|
720
|
+
// Server-initiated logins store redirect_uri as '/' — send the browser home
|
|
721
|
+
// with the error attached. Same for an unmatched state (expired or replayed).
|
|
722
|
+
if (!request || request.redirect_uri === '/')
|
|
723
|
+
return `/?${params}`;
|
|
724
|
+
// Client flow: echo the client's state and our issuer so the SPA's callback
|
|
725
|
+
// handler recognises the redirect as its own rather than re-entering here.
|
|
726
|
+
if (request.state)
|
|
727
|
+
params.set('state', request.state);
|
|
728
|
+
params.set('iss', config.issuer);
|
|
729
|
+
return `${request.redirect_uri}?${params}`;
|
|
355
730
|
}
|
|
356
731
|
// --- Token Endpoint ---
|
|
357
732
|
export async function handleToken(config, body, dpopHeader, requestUrl) {
|
|
358
733
|
const grantType = body.grant_type;
|
|
359
734
|
if (!grantType)
|
|
360
|
-
throw new
|
|
735
|
+
throw new OAuthError('invalid_request', 'grant_type is required');
|
|
361
736
|
if (grantType === 'authorization_code') {
|
|
362
737
|
return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
|
|
363
738
|
}
|
|
364
739
|
else if (grantType === 'refresh_token') {
|
|
365
740
|
return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
|
|
366
741
|
}
|
|
367
|
-
throw new
|
|
742
|
+
throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
|
|
368
743
|
}
|
|
369
744
|
async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
|
|
370
745
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
@@ -437,20 +812,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
437
812
|
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
438
813
|
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
439
814
|
if (!fresh)
|
|
440
|
-
throw new
|
|
815
|
+
throw new OAuthError('invalid_request', 'DPoP jti replay detected');
|
|
441
816
|
const { refresh_token, client_id } = body;
|
|
442
817
|
if (!refresh_token || !client_id)
|
|
443
|
-
throw new
|
|
444
|
-
// Look up and validate refresh token
|
|
818
|
+
throw new OAuthError('invalid_request', 'Missing required parameters');
|
|
819
|
+
// Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
|
|
820
|
+
// `invalid_grant` — the client must treat them as terminal and re-authenticate.
|
|
445
821
|
const stored = await getRefreshToken(refresh_token);
|
|
446
822
|
if (!stored)
|
|
447
|
-
throw new
|
|
823
|
+
throw new OAuthError('invalid_grant', 'Invalid refresh token');
|
|
448
824
|
if (stored.revoked)
|
|
449
|
-
throw new
|
|
825
|
+
throw new OAuthError('invalid_grant', 'Refresh token revoked');
|
|
450
826
|
if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
|
|
451
|
-
throw new
|
|
827
|
+
throw new OAuthError('invalid_grant', 'Refresh token expired');
|
|
452
828
|
if (stored.client_id !== client_id)
|
|
453
|
-
throw new
|
|
829
|
+
throw new OAuthError('invalid_grant', 'client_id mismatch');
|
|
454
830
|
// Revoke old refresh token (rotation)
|
|
455
831
|
await revokeRefreshToken(refresh_token);
|
|
456
832
|
const did = stored.did;
|
|
@@ -488,18 +864,101 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
|
488
864
|
handle,
|
|
489
865
|
};
|
|
490
866
|
}
|
|
867
|
+
// --- Sessions obtained outside the redirect flow ---
|
|
868
|
+
/** A refusal from the endpoint {@link obtainSession} called, or an answer it cannot use. */
|
|
869
|
+
export class ObtainSessionError extends Error {
|
|
870
|
+
error;
|
|
871
|
+
status;
|
|
872
|
+
constructor(
|
|
873
|
+
/** The endpoint's error code, e.g. an XRPC error name. */
|
|
874
|
+
error, message, status) {
|
|
875
|
+
super(message);
|
|
876
|
+
this.error = error;
|
|
877
|
+
this.status = status;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Obtain a session for another account straight from its authorization server,
|
|
882
|
+
* outside the redirect flow: a group host creating an account for this app and
|
|
883
|
+
* handing it a session, say.
|
|
884
|
+
*
|
|
885
|
+
* `body` is POSTed as JSON to `url` as this app's OAuth client, the way the
|
|
886
|
+
* token endpoint is called: `client_id` and, for a confidential client, a client
|
|
887
|
+
* assertion addressed to the endpoint's origin, are added to it, and the request
|
|
888
|
+
* carries a DPoP proof from the app's key, answering a nonce challenge once.
|
|
889
|
+
* `headers` are sent too, for whatever else the endpoint wants to know (service
|
|
890
|
+
* auth from the viewer, typically).
|
|
891
|
+
*
|
|
892
|
+
* The response must hold an OAuth token response, at `session` or as the whole
|
|
893
|
+
* body. That session is stored for its `sub` exactly as a login's is, bound to
|
|
894
|
+
* the same DPoP key, so refresh and the PDS helpers work for that DID from here
|
|
895
|
+
* on. It is only accepted if the account's own authorization server is the one
|
|
896
|
+
* that answered: an endpoint cannot hand over a session for an account it does
|
|
897
|
+
* not serve.
|
|
898
|
+
*/
|
|
899
|
+
export async function obtainSession(config, url, body, opts = {}) {
|
|
900
|
+
const origin = new URL(url).origin;
|
|
901
|
+
const payload = {
|
|
902
|
+
...body,
|
|
903
|
+
...(await withClientAuth({ client_id: pdsClientId(config.issuer, config) }, config.issuer, config, origin)),
|
|
904
|
+
};
|
|
905
|
+
const send = async (nonce) => fetch(url, {
|
|
906
|
+
method: 'POST',
|
|
907
|
+
headers: {
|
|
908
|
+
...opts.headers,
|
|
909
|
+
'Content-Type': 'application/json',
|
|
910
|
+
DPoP: await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', url, undefined, nonce),
|
|
911
|
+
},
|
|
912
|
+
body: JSON.stringify(payload),
|
|
913
|
+
});
|
|
914
|
+
let res = await send();
|
|
915
|
+
let out = (await res.json().catch(() => ({})));
|
|
916
|
+
const nonce = res.headers.get('DPoP-Nonce');
|
|
917
|
+
if (!res.ok && out.error === 'use_dpop_nonce' && nonce) {
|
|
918
|
+
res = await send(nonce);
|
|
919
|
+
out = (await res.json().catch(() => ({})));
|
|
920
|
+
}
|
|
921
|
+
if (!res.ok) {
|
|
922
|
+
throw new ObtainSessionError(typeof out.error === 'string' ? out.error : 'UpstreamFailure', String(out.message ?? out.error_description ?? `${url} failed (${res.status})`), res.status);
|
|
923
|
+
}
|
|
924
|
+
const token = (typeof out.session === 'object' && out.session ? out.session : out);
|
|
925
|
+
const did = token.sub;
|
|
926
|
+
if (typeof did !== 'string' || typeof token.access_token !== 'string') {
|
|
927
|
+
throw new ObtainSessionError('NoSession', `${url} returned no session`, 502);
|
|
928
|
+
}
|
|
929
|
+
const { pdsEndpoint, authServerEndpoint, authServerMetadata } = await discoverAuthServer(did, _plcUrl);
|
|
930
|
+
if (new URL(authServerEndpoint).origin !== origin) {
|
|
931
|
+
throw new ObtainSessionError('WrongAuthServer', `${origin} is not the authorization server for ${did}`, 502);
|
|
932
|
+
}
|
|
933
|
+
await storeSession(did, {
|
|
934
|
+
pdsEndpoint,
|
|
935
|
+
pdsAuthServer: authServerEndpoint,
|
|
936
|
+
pdsTokenEndpoint: authServerMetadata.token_endpoint,
|
|
937
|
+
accessToken: token.access_token,
|
|
938
|
+
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : undefined,
|
|
939
|
+
dpopJkt: serverJkt,
|
|
940
|
+
tokenExpiresAt: typeof token.expires_in === 'number' ? Math.floor(Date.now() / 1000) + token.expires_in : undefined,
|
|
941
|
+
requestedScope: typeof body.scope === 'string' ? body.scope : undefined,
|
|
942
|
+
grantedScope: typeof token.scope === 'string' ? token.scope : undefined,
|
|
943
|
+
});
|
|
944
|
+
emit('oauth', 'session_obtained', { did, from: origin });
|
|
945
|
+
return { did, response: out };
|
|
946
|
+
}
|
|
491
947
|
// --- PDS Session Refresh ---
|
|
492
948
|
export async function refreshPdsSession(config, session) {
|
|
493
949
|
if (!session.refresh_token)
|
|
494
950
|
return null;
|
|
495
|
-
|
|
951
|
+
// Prefer the endpoint the auth server advertised when this session was
|
|
952
|
+
// authorized. Sessions predating that column fall back to the auth server
|
|
953
|
+
// (or, older still, the PDS) with the path this used to hardcode.
|
|
954
|
+
const tokenEndpoint = storedEndpoint(session.pds_token_endpoint, session.pds_auth_server || session.pds_endpoint, '/oauth/token');
|
|
496
955
|
const clientId = pdsClientId(config.issuer, config);
|
|
497
956
|
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
498
|
-
const body = new URLSearchParams({
|
|
957
|
+
const body = new URLSearchParams(await withClientAuth({
|
|
499
958
|
grant_type: 'refresh_token',
|
|
500
959
|
refresh_token: session.refresh_token,
|
|
501
960
|
client_id: clientId,
|
|
502
|
-
});
|
|
961
|
+
}, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
|
|
503
962
|
let tokenRes = await fetch(tokenEndpoint, {
|
|
504
963
|
method: 'POST',
|
|
505
964
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
|
|
@@ -526,16 +985,22 @@ export async function refreshPdsSession(config, session) {
|
|
|
526
985
|
did: session.did,
|
|
527
986
|
pds_endpoint: session.pds_endpoint,
|
|
528
987
|
});
|
|
988
|
+
await deleteSession(session.did);
|
|
529
989
|
return null;
|
|
530
990
|
}
|
|
531
991
|
const tokenData = await tokenRes.json();
|
|
532
992
|
// Update stored session
|
|
533
993
|
await storeSession(session.did, {
|
|
534
994
|
pdsEndpoint: session.pds_endpoint,
|
|
995
|
+
pdsAuthServer: session.pds_auth_server,
|
|
996
|
+
pdsTokenEndpoint: session.pds_token_endpoint,
|
|
535
997
|
accessToken: tokenData.access_token,
|
|
536
998
|
refreshToken: tokenData.refresh_token || session.refresh_token,
|
|
537
999
|
dpopJkt: session.dpop_jkt,
|
|
538
1000
|
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
1001
|
+
// A refresh renews the grant, it does not renegotiate it.
|
|
1002
|
+
requestedScope: session.requested_scope ?? undefined,
|
|
1003
|
+
grantedScope: typeof tokenData.scope === 'string' ? tokenData.scope : (session.granted_scope ?? undefined),
|
|
539
1004
|
});
|
|
540
1005
|
return {
|
|
541
1006
|
accessToken: tokenData.access_token,
|