@hatk/hatk 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backfill.d.ts +11 -0
- package/dist/backfill.d.ts.map +1 -0
- package/dist/backfill.js +328 -0
- package/dist/car.d.ts +5 -0
- package/dist/car.d.ts.map +1 -0
- package/dist/car.js +52 -0
- package/dist/cbor.d.ts +7 -0
- package/dist/cbor.d.ts.map +1 -0
- package/dist/cbor.js +89 -0
- package/dist/cid.d.ts +4 -0
- package/dist/cid.d.ts.map +1 -0
- package/dist/cid.js +39 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1663 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +43 -0
- package/dist/db.d.ts +134 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +1361 -0
- package/dist/feeds.d.ts +95 -0
- package/dist/feeds.d.ts.map +1 -0
- package/dist/feeds.js +144 -0
- package/dist/fts.d.ts +20 -0
- package/dist/fts.d.ts.map +1 -0
- package/dist/fts.js +762 -0
- package/dist/hydrate.d.ts +23 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +75 -0
- package/dist/indexer.d.ts +14 -0
- package/dist/indexer.d.ts.map +1 -0
- package/dist/indexer.js +316 -0
- package/dist/labels.d.ts +29 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +111 -0
- package/dist/lex-types.d.ts +401 -0
- package/dist/lex-types.d.ts.map +1 -0
- package/dist/lex-types.js +4 -0
- package/dist/lexicon-resolve.d.ts +14 -0
- package/dist/lexicon-resolve.d.ts.map +1 -0
- package/dist/lexicon-resolve.js +280 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +23 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +148 -0
- package/dist/mst.d.ts +6 -0
- package/dist/mst.d.ts.map +1 -0
- package/dist/mst.js +30 -0
- package/dist/oauth/client.d.ts +16 -0
- package/dist/oauth/client.d.ts.map +1 -0
- package/dist/oauth/client.js +54 -0
- package/dist/oauth/crypto.d.ts +28 -0
- package/dist/oauth/crypto.d.ts.map +1 -0
- package/dist/oauth/crypto.js +101 -0
- package/dist/oauth/db.d.ts +47 -0
- package/dist/oauth/db.d.ts.map +1 -0
- package/dist/oauth/db.js +139 -0
- package/dist/oauth/discovery.d.ts +22 -0
- package/dist/oauth/discovery.d.ts.map +1 -0
- package/dist/oauth/discovery.js +50 -0
- package/dist/oauth/dpop.d.ts +11 -0
- package/dist/oauth/dpop.d.ts.map +1 -0
- package/dist/oauth/dpop.js +56 -0
- package/dist/oauth/hooks.d.ts +10 -0
- package/dist/oauth/hooks.d.ts.map +1 -0
- package/dist/oauth/hooks.js +40 -0
- package/dist/oauth/server.d.ts +86 -0
- package/dist/oauth/server.d.ts.map +1 -0
- package/dist/oauth/server.js +572 -0
- package/dist/opengraph.d.ts +34 -0
- package/dist/opengraph.d.ts.map +1 -0
- package/dist/opengraph.js +198 -0
- package/dist/schema.d.ts +51 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +358 -0
- package/dist/seed.d.ts +29 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +86 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1024 -0
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +48 -0
- package/dist/test-browser.d.ts +14 -0
- package/dist/test-browser.d.ts.map +1 -0
- package/dist/test-browser.js +26 -0
- package/dist/test.d.ts +47 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +256 -0
- package/dist/views.d.ts +40 -0
- package/dist/views.d.ts.map +1 -0
- package/dist/views.js +178 -0
- package/dist/vite-plugin.d.ts +5 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +86 -0
- package/dist/xrpc-client.d.ts +18 -0
- package/dist/xrpc-client.d.ts.map +1 -0
- package/dist/xrpc-client.js +54 -0
- package/dist/xrpc.d.ts +53 -0
- package/dist/xrpc.d.ts.map +1 -0
- package/dist/xrpc.js +139 -0
- package/fonts/Inter-Regular.woff +0 -0
- package/package.json +41 -0
- package/public/admin-auth.js +320 -0
- package/public/admin.html +2166 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// packages/hatk/src/oauth/server.ts
|
|
2
|
+
import { generateKeyPair, importPrivateKey, computeJwkThumbprint, signJwt, parseJwt, verifyEs256, importPublicKey, randomToken, sha256, base64UrlEncode, } from "./crypto.js";
|
|
3
|
+
import { parseDpopProof, createDpopProof } from "./dpop.js";
|
|
4
|
+
import { resolveClient, validateRedirectUri, isLoopbackClient } from "./client.js";
|
|
5
|
+
import { discoverAuthServer, resolveHandle } from "./discovery.js";
|
|
6
|
+
import { getServerKey, storeServerKey, storeOAuthRequest, getOAuthRequest, deleteOAuthRequest, storeAuthCode, consumeAuthCode, storeSession, checkAndStoreDpopJti, cleanupExpiredOAuth, storeRefreshToken, getRefreshToken, revokeRefreshToken, } from "./db.js";
|
|
7
|
+
import { emit } from "../logger.js";
|
|
8
|
+
import { querySQL } from "../db.js";
|
|
9
|
+
import { fireOnLoginHook } from "./hooks.js";
|
|
10
|
+
const SERVER_KEY_KID = 'appview-oauth-key';
|
|
11
|
+
async function resolveHandleForDid(did) {
|
|
12
|
+
const rows = (await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]));
|
|
13
|
+
return rows[0]?.handle || undefined;
|
|
14
|
+
}
|
|
15
|
+
/** Convert localhost to 127.0.0.1 for RFC 8252 compliance (PDS requirement). */
|
|
16
|
+
function toLoopbackIp(url) {
|
|
17
|
+
return url.replace(/\/\/localhost([:/])/g, '//127.0.0.1$1').replace(/\/\/localhost$/, '//127.0.0.1');
|
|
18
|
+
}
|
|
19
|
+
/** PDS-facing redirect_uri: loopback must use 127.0.0.1 per RFC 8252. */
|
|
20
|
+
function pdsRedirectUri(issuer) {
|
|
21
|
+
if (isLoopbackClient(issuer))
|
|
22
|
+
return `${toLoopbackIp(issuer)}/oauth/callback`;
|
|
23
|
+
return `${issuer}/oauth/callback`;
|
|
24
|
+
}
|
|
25
|
+
/** PDS-facing client_id: loopback encodes redirect_uri+scope per AT Proto spec, production uses metadata URL. */
|
|
26
|
+
function pdsClientId(issuer, config) {
|
|
27
|
+
if (isLoopbackClient(issuer)) {
|
|
28
|
+
const redirectUri = pdsRedirectUri(issuer);
|
|
29
|
+
// Use scope from matching client config (try bare issuer, then metadata URL)
|
|
30
|
+
const client = config?.clients.find((c) => c.client_id === issuer) ||
|
|
31
|
+
config?.clients.find((c) => c.client_id === `${issuer}/oauth-client-metadata.json`);
|
|
32
|
+
const scope = client?.scope || 'atproto transition:generic';
|
|
33
|
+
return `http://localhost/?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
|
34
|
+
}
|
|
35
|
+
return `${issuer}/oauth-client-metadata.json`;
|
|
36
|
+
}
|
|
37
|
+
let serverPrivateJwk;
|
|
38
|
+
let serverPublicJwk;
|
|
39
|
+
let serverPrivateKey;
|
|
40
|
+
let serverJkt;
|
|
41
|
+
let _plcUrl;
|
|
42
|
+
let _relayUrl;
|
|
43
|
+
export async function initOAuth(_config, plcUrl, relayUrl) {
|
|
44
|
+
_plcUrl = plcUrl;
|
|
45
|
+
_relayUrl = relayUrl;
|
|
46
|
+
// Load or generate server key pair
|
|
47
|
+
const existing = await getServerKey(SERVER_KEY_KID);
|
|
48
|
+
if (existing) {
|
|
49
|
+
serverPrivateJwk = JSON.parse(existing.privateKey);
|
|
50
|
+
serverPublicJwk = JSON.parse(existing.publicKey);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const kp = await generateKeyPair();
|
|
54
|
+
serverPrivateJwk = kp.privateJwk;
|
|
55
|
+
serverPublicJwk = kp.publicJwk;
|
|
56
|
+
await storeServerKey(SERVER_KEY_KID, JSON.stringify(serverPrivateJwk), JSON.stringify(serverPublicJwk));
|
|
57
|
+
}
|
|
58
|
+
serverPrivateKey = await importPrivateKey(serverPrivateJwk);
|
|
59
|
+
serverJkt = await computeJwkThumbprint(serverPublicJwk);
|
|
60
|
+
// Periodic cleanup of expired OAuth data
|
|
61
|
+
setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
|
|
62
|
+
}
|
|
63
|
+
// --- Metadata Endpoints ---
|
|
64
|
+
export function getAuthServerMetadata(issuer, config) {
|
|
65
|
+
return {
|
|
66
|
+
issuer,
|
|
67
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
68
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
69
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
70
|
+
pushed_authorization_request_endpoint: `${issuer}/oauth/par`,
|
|
71
|
+
jwks_uri: `${issuer}/oauth/jwks`,
|
|
72
|
+
scopes_supported: config.scopes,
|
|
73
|
+
subject_types_supported: ['public'],
|
|
74
|
+
response_types_supported: ['code'],
|
|
75
|
+
response_modes_supported: ['query', 'fragment'],
|
|
76
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
77
|
+
code_challenge_methods_supported: ['S256'],
|
|
78
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
79
|
+
dpop_signing_alg_values_supported: ['ES256'],
|
|
80
|
+
require_pushed_authorization_requests: false,
|
|
81
|
+
authorization_response_iss_parameter_supported: true,
|
|
82
|
+
client_id_metadata_document_supported: true,
|
|
83
|
+
protected_resources: [issuer],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function getProtectedResourceMetadata(issuer, config) {
|
|
87
|
+
return {
|
|
88
|
+
resource: issuer,
|
|
89
|
+
authorization_servers: [issuer],
|
|
90
|
+
bearer_methods_supported: ['header'],
|
|
91
|
+
scopes_supported: config.scopes,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function getJwks() {
|
|
95
|
+
return {
|
|
96
|
+
keys: [
|
|
97
|
+
{
|
|
98
|
+
...serverPublicJwk,
|
|
99
|
+
kid: SERVER_KEY_KID,
|
|
100
|
+
use: 'sig',
|
|
101
|
+
alg: 'ES256',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function getClientMetadata(issuer, config) {
|
|
107
|
+
// Find the metadata client entry to get its scope
|
|
108
|
+
const metadataClientId = `${issuer}/oauth-client-metadata.json`;
|
|
109
|
+
const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
|
|
110
|
+
return {
|
|
111
|
+
client_id: metadataClientId,
|
|
112
|
+
client_name: clientConfig?.client_name || 'hatk',
|
|
113
|
+
redirect_uris: [`${issuer}/oauth/callback`],
|
|
114
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
115
|
+
response_types: ['code'],
|
|
116
|
+
token_endpoint_auth_method: 'none',
|
|
117
|
+
dpop_bound_access_tokens: true,
|
|
118
|
+
scope: clientConfig?.scope || 'atproto transition:generic',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// --- PAR Endpoint ---
|
|
122
|
+
export async function handlePar(config, body, dpopHeader, requestUrl) {
|
|
123
|
+
// Validate client DPoP proof
|
|
124
|
+
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
125
|
+
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
126
|
+
if (!fresh)
|
|
127
|
+
throw new Error('DPoP jti replay detected');
|
|
128
|
+
// Validate client
|
|
129
|
+
const clientId = body.client_id;
|
|
130
|
+
if (!clientId)
|
|
131
|
+
throw new Error('client_id is required');
|
|
132
|
+
const client = resolveClient(clientId, config.clients);
|
|
133
|
+
if (!client)
|
|
134
|
+
throw new Error(`Unknown client: ${clientId}`);
|
|
135
|
+
// Validate redirect_uri
|
|
136
|
+
const redirectUri = body.redirect_uri;
|
|
137
|
+
if (!redirectUri)
|
|
138
|
+
throw new Error('redirect_uri is required');
|
|
139
|
+
if (!validateRedirectUri(client, redirectUri))
|
|
140
|
+
throw new Error('Invalid redirect_uri');
|
|
141
|
+
// Validate PKCE
|
|
142
|
+
if (!body.code_challenge)
|
|
143
|
+
throw new Error('code_challenge is required');
|
|
144
|
+
if (body.code_challenge_method && body.code_challenge_method !== 'S256')
|
|
145
|
+
throw new Error('Only S256 supported');
|
|
146
|
+
// Resolve DID from login_hint
|
|
147
|
+
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
|
+
let pdsRequestUri;
|
|
153
|
+
let pdsAuthServer;
|
|
154
|
+
let pdsCodeVerifier;
|
|
155
|
+
let pdsState;
|
|
156
|
+
if (did) {
|
|
157
|
+
const discovery = await discoverAuthServer(did, _plcUrl);
|
|
158
|
+
pdsAuthServer = discovery.authServerEndpoint;
|
|
159
|
+
// Create PKCE for our PAR to the PDS
|
|
160
|
+
pdsCodeVerifier = randomToken();
|
|
161
|
+
const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
|
|
162
|
+
pdsState = randomToken(); // unique state to correlate callback
|
|
163
|
+
// PAR to the PDS
|
|
164
|
+
const parEndpoint = discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
|
|
165
|
+
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
|
|
166
|
+
const pdsParBody = new URLSearchParams({
|
|
167
|
+
client_id: pdsClientId(config.issuer, config),
|
|
168
|
+
redirect_uri: pdsRedirectUri(config.issuer),
|
|
169
|
+
response_type: 'code',
|
|
170
|
+
code_challenge: pdsCodeChallenge,
|
|
171
|
+
code_challenge_method: 'S256',
|
|
172
|
+
scope: body.scope || 'atproto transition:generic',
|
|
173
|
+
login_hint: body.login_hint || did,
|
|
174
|
+
state: pdsState,
|
|
175
|
+
});
|
|
176
|
+
const pdsParRes = await fetch(parEndpoint, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
179
|
+
body: pdsParBody.toString(),
|
|
180
|
+
});
|
|
181
|
+
if (!pdsParRes.ok) {
|
|
182
|
+
// Handle DPoP nonce retry
|
|
183
|
+
const errBody = await pdsParRes.json().catch(() => ({}));
|
|
184
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
185
|
+
const nonce = pdsParRes.headers.get('DPoP-Nonce');
|
|
186
|
+
if (nonce) {
|
|
187
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
|
|
188
|
+
const retryRes = await fetch(parEndpoint, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
191
|
+
body: pdsParBody.toString(),
|
|
192
|
+
});
|
|
193
|
+
if (!retryRes.ok) {
|
|
194
|
+
const retryErr = await retryRes.json().catch(() => ({}));
|
|
195
|
+
emit('oauth', 'pds_par_error', {
|
|
196
|
+
status: retryRes.status,
|
|
197
|
+
error: retryErr.error,
|
|
198
|
+
error_description: retryErr.error_description,
|
|
199
|
+
retry: true,
|
|
200
|
+
});
|
|
201
|
+
throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
|
|
202
|
+
}
|
|
203
|
+
const retryData = await retryRes.json();
|
|
204
|
+
pdsRequestUri = retryData.request_uri;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
emit('oauth', 'pds_par_error', {
|
|
209
|
+
status: pdsParRes.status,
|
|
210
|
+
error: errBody.error,
|
|
211
|
+
error_description: errBody.error_description,
|
|
212
|
+
endpoint: parEndpoint,
|
|
213
|
+
client_id: pdsParBody.get('client_id'),
|
|
214
|
+
redirect_uri: pdsParBody.get('redirect_uri'),
|
|
215
|
+
});
|
|
216
|
+
throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const pdsParData = await pdsParRes.json();
|
|
221
|
+
pdsRequestUri = pdsParData.request_uri;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Store our authorization request
|
|
225
|
+
const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
|
|
226
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 600;
|
|
227
|
+
await storeOAuthRequest(requestUri, {
|
|
228
|
+
clientId,
|
|
229
|
+
redirectUri,
|
|
230
|
+
scope: body.scope,
|
|
231
|
+
state: body.state,
|
|
232
|
+
codeChallenge: body.code_challenge,
|
|
233
|
+
codeChallengeMethod: body.code_challenge_method || 'S256',
|
|
234
|
+
dpopJkt: dpop.jkt,
|
|
235
|
+
pdsRequestUri,
|
|
236
|
+
pdsAuthServer,
|
|
237
|
+
pdsCodeVerifier,
|
|
238
|
+
pdsState,
|
|
239
|
+
did,
|
|
240
|
+
loginHint: body.login_hint,
|
|
241
|
+
expiresAt,
|
|
242
|
+
});
|
|
243
|
+
return { request_uri: requestUri, expires_in: 600 };
|
|
244
|
+
}
|
|
245
|
+
// --- Authorize Endpoint ---
|
|
246
|
+
export function buildAuthorizeRedirect(config, request) {
|
|
247
|
+
if (!request.pds_auth_server || !request.pds_request_uri) {
|
|
248
|
+
throw new Error('Authorization request missing PDS data');
|
|
249
|
+
}
|
|
250
|
+
const params = new URLSearchParams({
|
|
251
|
+
request_uri: request.pds_request_uri,
|
|
252
|
+
client_id: pdsClientId(config.issuer, config),
|
|
253
|
+
});
|
|
254
|
+
return `${request.pds_auth_server}/oauth/authorize?${params}`;
|
|
255
|
+
}
|
|
256
|
+
// --- OAuth Callback (PDS redirects here) ---
|
|
257
|
+
export async function handleCallback(config, code, state, iss) {
|
|
258
|
+
// Find the matching OAuth request by pds_state (unique per PAR)
|
|
259
|
+
const { querySQL } = await import("../db.js");
|
|
260
|
+
let request = null;
|
|
261
|
+
if (state) {
|
|
262
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
|
|
263
|
+
state,
|
|
264
|
+
Math.floor(Date.now() / 1000),
|
|
265
|
+
]);
|
|
266
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
267
|
+
}
|
|
268
|
+
// Fallback: match by iss (legacy requests without pds_state)
|
|
269
|
+
if (!request && iss) {
|
|
270
|
+
const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_auth_server = $1 AND expires_at > $2 ORDER BY expires_at DESC`, [iss, Math.floor(Date.now() / 1000)]);
|
|
271
|
+
request = rows.length > 0 ? rows[0] : null;
|
|
272
|
+
}
|
|
273
|
+
if (!request)
|
|
274
|
+
throw new Error('No matching authorization request found');
|
|
275
|
+
// Exchange code at PDS token endpoint
|
|
276
|
+
const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
|
|
277
|
+
const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
278
|
+
const tokenBody = new URLSearchParams({
|
|
279
|
+
grant_type: 'authorization_code',
|
|
280
|
+
code,
|
|
281
|
+
redirect_uri: pdsRedirectUri(config.issuer),
|
|
282
|
+
client_id: pdsClientId(config.issuer, config),
|
|
283
|
+
code_verifier: request.pds_code_verifier,
|
|
284
|
+
});
|
|
285
|
+
let tokenRes = await fetch(tokenEndpoint, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
|
|
288
|
+
body: tokenBody.toString(),
|
|
289
|
+
});
|
|
290
|
+
// Handle DPoP nonce retry
|
|
291
|
+
if (!tokenRes.ok) {
|
|
292
|
+
const errBody = await tokenRes.json().catch(() => ({}));
|
|
293
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
294
|
+
const nonce = tokenRes.headers.get('DPoP-Nonce');
|
|
295
|
+
if (nonce) {
|
|
296
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint, undefined, nonce);
|
|
297
|
+
tokenRes = await fetch(tokenEndpoint, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
300
|
+
body: tokenBody.toString(),
|
|
301
|
+
});
|
|
302
|
+
if (!tokenRes.ok) {
|
|
303
|
+
const retryErr = await tokenRes.json().catch(() => ({}));
|
|
304
|
+
emit('oauth', 'pds_token_exchange_error', {
|
|
305
|
+
status: tokenRes.status,
|
|
306
|
+
error: retryErr.error,
|
|
307
|
+
error_description: retryErr.error_description,
|
|
308
|
+
retry: true,
|
|
309
|
+
});
|
|
310
|
+
throw new Error(`PDS token exchange failed: ${tokenRes.status} ${retryErr.error_description || retryErr.error || ''}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
throw new Error(`PDS token exchange failed: DPoP nonce required but not provided`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
emit('oauth', 'pds_token_exchange_error', {
|
|
319
|
+
status: tokenRes.status,
|
|
320
|
+
error: errBody.error,
|
|
321
|
+
error_description: errBody.error_description,
|
|
322
|
+
client_id: tokenBody.get('client_id'),
|
|
323
|
+
redirect_uri: tokenBody.get('redirect_uri'),
|
|
324
|
+
});
|
|
325
|
+
throw new Error(`PDS token exchange failed: ${tokenRes.status} ${errBody.error_description || errBody.error || ''}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const tokenData = await tokenRes.json();
|
|
329
|
+
const did = tokenData.sub;
|
|
330
|
+
if (!did)
|
|
331
|
+
throw new Error('PDS token response missing sub (DID)');
|
|
332
|
+
// Store PDS session server-side
|
|
333
|
+
await storeSession(did, {
|
|
334
|
+
pdsEndpoint: request.pds_auth_server.replace('/oauth', ''),
|
|
335
|
+
accessToken: tokenData.access_token,
|
|
336
|
+
refreshToken: tokenData.refresh_token,
|
|
337
|
+
dpopJkt: serverJkt,
|
|
338
|
+
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
339
|
+
});
|
|
340
|
+
await fireOnLoginHook(did);
|
|
341
|
+
// Generate authorization code for the client
|
|
342
|
+
const clientCode = randomToken();
|
|
343
|
+
await storeAuthCode(clientCode, request.request_uri);
|
|
344
|
+
// Update the request with the DID (in case it wasn't set during PAR)
|
|
345
|
+
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);
|
|
348
|
+
}
|
|
349
|
+
// Build redirect back to client
|
|
350
|
+
const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
|
|
351
|
+
if (request.state)
|
|
352
|
+
params.set('state', request.state);
|
|
353
|
+
const clientRedirectUri = `${request.redirect_uri}?${params}`;
|
|
354
|
+
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
|
|
355
|
+
}
|
|
356
|
+
// --- Token Endpoint ---
|
|
357
|
+
export async function handleToken(config, body, dpopHeader, requestUrl) {
|
|
358
|
+
const grantType = body.grant_type;
|
|
359
|
+
if (!grantType)
|
|
360
|
+
throw new Error('grant_type is required');
|
|
361
|
+
if (grantType === 'authorization_code') {
|
|
362
|
+
return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
|
|
363
|
+
}
|
|
364
|
+
else if (grantType === 'refresh_token') {
|
|
365
|
+
return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
|
|
366
|
+
}
|
|
367
|
+
throw new Error(`Unsupported grant_type: ${grantType}`);
|
|
368
|
+
}
|
|
369
|
+
async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
|
|
370
|
+
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
371
|
+
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
372
|
+
if (!fresh)
|
|
373
|
+
throw new Error('DPoP jti replay detected');
|
|
374
|
+
const { code, client_id, redirect_uri, code_verifier } = body;
|
|
375
|
+
if (!code || !client_id || !redirect_uri || !code_verifier) {
|
|
376
|
+
throw new Error('Missing required parameters');
|
|
377
|
+
}
|
|
378
|
+
// Consume one-time code
|
|
379
|
+
const requestUri = await consumeAuthCode(code);
|
|
380
|
+
if (!requestUri)
|
|
381
|
+
throw new Error('Invalid or expired authorization code');
|
|
382
|
+
const request = await getOAuthRequest(requestUri);
|
|
383
|
+
if (!request)
|
|
384
|
+
throw new Error('Authorization request not found');
|
|
385
|
+
// Validate
|
|
386
|
+
if (request.client_id !== client_id)
|
|
387
|
+
throw new Error('client_id mismatch');
|
|
388
|
+
if (request.redirect_uri !== redirect_uri)
|
|
389
|
+
throw new Error('redirect_uri mismatch');
|
|
390
|
+
// Verify PKCE
|
|
391
|
+
const challengeHash = base64UrlEncode(await sha256(code_verifier));
|
|
392
|
+
if (challengeHash !== request.code_challenge)
|
|
393
|
+
throw new Error('PKCE verification failed');
|
|
394
|
+
// Verify DPoP key matches PAR
|
|
395
|
+
if (request.dpop_jkt !== dpop.jkt)
|
|
396
|
+
throw new Error('DPoP key mismatch');
|
|
397
|
+
// Find the DID from the PDS session (stored during callback)
|
|
398
|
+
const did = request.did;
|
|
399
|
+
if (!did)
|
|
400
|
+
throw new Error('No DID associated with this request');
|
|
401
|
+
// Issue appview access token
|
|
402
|
+
const tokenId = randomToken();
|
|
403
|
+
const now = Math.floor(Date.now() / 1000);
|
|
404
|
+
const expiresIn = 3600;
|
|
405
|
+
const accessToken = await signJwt({ typ: 'at+jwt', alg: 'ES256', kid: SERVER_KEY_KID }, {
|
|
406
|
+
iss: config.issuer,
|
|
407
|
+
sub: did,
|
|
408
|
+
aud: config.issuer,
|
|
409
|
+
client_id,
|
|
410
|
+
scope: request.scope || 'atproto',
|
|
411
|
+
jti: tokenId,
|
|
412
|
+
iat: now,
|
|
413
|
+
exp: now + expiresIn,
|
|
414
|
+
cnf: { jkt: dpop.jkt },
|
|
415
|
+
}, serverPrivateKey);
|
|
416
|
+
// Issue refresh token with rotation support
|
|
417
|
+
const refreshToken = randomToken();
|
|
418
|
+
await storeRefreshToken(refreshToken, {
|
|
419
|
+
clientId: client_id,
|
|
420
|
+
did,
|
|
421
|
+
dpopJkt: dpop.jkt,
|
|
422
|
+
scope: request.scope || 'atproto',
|
|
423
|
+
});
|
|
424
|
+
// Cleanup
|
|
425
|
+
await deleteOAuthRequest(requestUri);
|
|
426
|
+
const handle = await resolveHandleForDid(did);
|
|
427
|
+
return {
|
|
428
|
+
access_token: accessToken,
|
|
429
|
+
token_type: 'DPoP',
|
|
430
|
+
expires_in: expiresIn,
|
|
431
|
+
refresh_token: refreshToken,
|
|
432
|
+
sub: did,
|
|
433
|
+
handle,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
|
|
437
|
+
const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
|
|
438
|
+
const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
|
|
439
|
+
if (!fresh)
|
|
440
|
+
throw new Error('DPoP jti replay detected');
|
|
441
|
+
const { refresh_token, client_id } = body;
|
|
442
|
+
if (!refresh_token || !client_id)
|
|
443
|
+
throw new Error('Missing required parameters');
|
|
444
|
+
// Look up and validate refresh token
|
|
445
|
+
const stored = await getRefreshToken(refresh_token);
|
|
446
|
+
if (!stored)
|
|
447
|
+
throw new Error('Invalid refresh token');
|
|
448
|
+
if (stored.revoked)
|
|
449
|
+
throw new Error('Refresh token revoked');
|
|
450
|
+
if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
|
|
451
|
+
throw new Error('Refresh token expired');
|
|
452
|
+
if (stored.client_id !== client_id)
|
|
453
|
+
throw new Error('client_id mismatch');
|
|
454
|
+
// Revoke old refresh token (rotation)
|
|
455
|
+
await revokeRefreshToken(refresh_token);
|
|
456
|
+
const did = stored.did;
|
|
457
|
+
const scope = stored.scope || 'atproto';
|
|
458
|
+
// Issue new access token
|
|
459
|
+
const tokenId = randomToken();
|
|
460
|
+
const now = Math.floor(Date.now() / 1000);
|
|
461
|
+
const expiresIn = 3600;
|
|
462
|
+
const accessToken = await signJwt({ typ: 'at+jwt', alg: 'ES256', kid: SERVER_KEY_KID }, {
|
|
463
|
+
iss: config.issuer,
|
|
464
|
+
sub: did,
|
|
465
|
+
aud: config.issuer,
|
|
466
|
+
client_id,
|
|
467
|
+
scope,
|
|
468
|
+
jti: tokenId,
|
|
469
|
+
iat: now,
|
|
470
|
+
exp: now + expiresIn,
|
|
471
|
+
cnf: { jkt: dpop.jkt },
|
|
472
|
+
}, serverPrivateKey);
|
|
473
|
+
// Issue new refresh token (rotation)
|
|
474
|
+
const newRefreshToken = randomToken();
|
|
475
|
+
await storeRefreshToken(newRefreshToken, {
|
|
476
|
+
clientId: client_id,
|
|
477
|
+
did,
|
|
478
|
+
dpopJkt: dpop.jkt,
|
|
479
|
+
scope,
|
|
480
|
+
});
|
|
481
|
+
const handle = await resolveHandleForDid(did);
|
|
482
|
+
return {
|
|
483
|
+
access_token: accessToken,
|
|
484
|
+
token_type: 'DPoP',
|
|
485
|
+
expires_in: expiresIn,
|
|
486
|
+
refresh_token: newRefreshToken,
|
|
487
|
+
sub: did,
|
|
488
|
+
handle,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// --- PDS Session Refresh ---
|
|
492
|
+
export async function refreshPdsSession(config, session) {
|
|
493
|
+
if (!session.refresh_token)
|
|
494
|
+
return null;
|
|
495
|
+
const tokenEndpoint = `${session.pds_endpoint}/oauth/token`;
|
|
496
|
+
const clientId = pdsClientId(config.issuer, config);
|
|
497
|
+
const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
|
|
498
|
+
const body = new URLSearchParams({
|
|
499
|
+
grant_type: 'refresh_token',
|
|
500
|
+
refresh_token: session.refresh_token,
|
|
501
|
+
client_id: clientId,
|
|
502
|
+
});
|
|
503
|
+
let tokenRes = await fetch(tokenEndpoint, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
|
|
506
|
+
body: body.toString(),
|
|
507
|
+
});
|
|
508
|
+
// Handle DPoP nonce retry
|
|
509
|
+
if (!tokenRes.ok) {
|
|
510
|
+
const errBody = await tokenRes.json().catch(() => ({}));
|
|
511
|
+
if (errBody.error === 'use_dpop_nonce') {
|
|
512
|
+
const nonce = tokenRes.headers.get('DPoP-Nonce');
|
|
513
|
+
if (nonce) {
|
|
514
|
+
const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint, undefined, nonce);
|
|
515
|
+
tokenRes = await fetch(tokenEndpoint, {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
|
|
518
|
+
body: body.toString(),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (!tokenRes.ok) {
|
|
524
|
+
emit('oauth', 'pds_session_refresh_error', {
|
|
525
|
+
status: tokenRes.status,
|
|
526
|
+
did: session.did,
|
|
527
|
+
pds_endpoint: session.pds_endpoint,
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
const tokenData = await tokenRes.json();
|
|
532
|
+
// Update stored session
|
|
533
|
+
await storeSession(session.did, {
|
|
534
|
+
pdsEndpoint: session.pds_endpoint,
|
|
535
|
+
accessToken: tokenData.access_token,
|
|
536
|
+
refreshToken: tokenData.refresh_token || session.refresh_token,
|
|
537
|
+
dpopJkt: session.dpop_jkt,
|
|
538
|
+
tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
accessToken: tokenData.access_token,
|
|
542
|
+
refreshToken: tokenData.refresh_token,
|
|
543
|
+
expiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
// --- Token Validation (for API calls) ---
|
|
547
|
+
export async function authenticate(authHeader, dpopHeader, method, url) {
|
|
548
|
+
if (!authHeader)
|
|
549
|
+
return null;
|
|
550
|
+
const dpopMatch = authHeader.match(/^DPoP\s+(.+)$/i);
|
|
551
|
+
if (!dpopMatch)
|
|
552
|
+
return null;
|
|
553
|
+
if (!dpopHeader)
|
|
554
|
+
return null;
|
|
555
|
+
const token = dpopMatch[1];
|
|
556
|
+
const { payload, signatureInput, signature } = parseJwt(token);
|
|
557
|
+
// Check expiration
|
|
558
|
+
const now = Math.floor(Date.now() / 1000);
|
|
559
|
+
if (payload.exp && payload.exp < now)
|
|
560
|
+
return null;
|
|
561
|
+
// Verify DPoP proof
|
|
562
|
+
const dpop = await parseDpopProof(dpopHeader, method, url, undefined, token);
|
|
563
|
+
// Verify token's cnf.jkt matches DPoP key
|
|
564
|
+
if (payload.cnf?.jkt && payload.cnf.jkt !== dpop.jkt)
|
|
565
|
+
return null;
|
|
566
|
+
// Verify token signature with our public key
|
|
567
|
+
const publicKey = await importPublicKey(serverPublicJwk);
|
|
568
|
+
const valid = await verifyEs256(publicKey, signature, signatureInput);
|
|
569
|
+
if (!valid)
|
|
570
|
+
return null;
|
|
571
|
+
return { did: payload.sub };
|
|
572
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { XrpcContext } from './xrpc.ts';
|
|
2
|
+
/** Virtual DOM node for satori rendering */
|
|
3
|
+
export interface SatoriNode {
|
|
4
|
+
type: string;
|
|
5
|
+
props: {
|
|
6
|
+
style?: Record<string, any>;
|
|
7
|
+
children?: (SatoriNode | string)[] | string;
|
|
8
|
+
src?: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
[key: string]: any;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Context passed to opengraph generate() functions */
|
|
15
|
+
export interface OpengraphContext extends XrpcContext {
|
|
16
|
+
fetchImage: (url: string) => Promise<string | null>;
|
|
17
|
+
}
|
|
18
|
+
/** Return type for opengraph generate() functions */
|
|
19
|
+
export interface OpengraphResult {
|
|
20
|
+
element: SatoriNode;
|
|
21
|
+
options?: {
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
fonts?: any[];
|
|
25
|
+
};
|
|
26
|
+
meta?: {
|
|
27
|
+
title?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare function initOpengraph(ogDir: string): Promise<void>;
|
|
32
|
+
export declare function handleOpengraphRequest(pathname: string): Promise<Buffer | null>;
|
|
33
|
+
export declare function buildOgMeta(pathname: string, origin: string): string | null;
|
|
34
|
+
//# sourceMappingURL=opengraph.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opengraph.d.ts","sourceRoot":"","sources":["../src/opengraph.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE5C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;QAC3B,QAAQ,CAAC,EAAE,CAAC,UAAU,GAAG,MAAM,CAAC,EAAE,GAAG,MAAM,CAAA;QAC3C,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KACnB,CAAA;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACpD;AAED,qDAAqD;AACrD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;KAAE,CAAA;IAC5D,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAChD;AAkCD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoGhE;AAED,wBAAsB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8BrF;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyC3E"}
|