@getcirrus/oauth-provider 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +284 -0
- package/dist/index.d.ts +591 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1395 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
import { EmbeddedJWK, base64url, calculateJwkThumbprint, errors, jwtVerify } from "jose";
|
|
2
|
+
import { ensureValidDid } from "@atproto/syntax";
|
|
3
|
+
import { oauthClientMetadataSchema } from "@atproto/oauth-types";
|
|
4
|
+
|
|
5
|
+
//#region src/pkce.ts
|
|
6
|
+
/**
|
|
7
|
+
* PKCE (Proof Key for Code Exchange) verification
|
|
8
|
+
* Implements RFC 7636 with S256 challenge method
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Generate the S256 code challenge from a verifier
|
|
12
|
+
* challenge = BASE64URL(SHA256(verifier))
|
|
13
|
+
*/
|
|
14
|
+
async function generateCodeChallenge(verifier) {
|
|
15
|
+
const data = new TextEncoder().encode(verifier);
|
|
16
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
17
|
+
return base64url.encode(new Uint8Array(hash));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Verify a PKCE code challenge against a verifier
|
|
21
|
+
* @param verifier The code verifier from the token request
|
|
22
|
+
* @param challenge The code challenge from the authorization request
|
|
23
|
+
* @param method The challenge method (only S256 supported for AT Protocol)
|
|
24
|
+
* @returns true if the verifier matches the challenge
|
|
25
|
+
*/
|
|
26
|
+
async function verifyPkceChallenge(verifier, challenge, method) {
|
|
27
|
+
if (method !== "S256") throw new Error("Only S256 challenge method is supported");
|
|
28
|
+
if (verifier.length < 43 || verifier.length > 128) return false;
|
|
29
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(verifier)) return false;
|
|
30
|
+
return await generateCodeChallenge(verifier) === challenge;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/encoding.ts
|
|
35
|
+
/**
|
|
36
|
+
* Shared encoding utilities for OAuth provider
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Generate a cryptographically random string
|
|
40
|
+
*
|
|
41
|
+
* @param byteLength Number of random bytes (default: 32 = 256 bits)
|
|
42
|
+
* @returns Base64URL-encoded random string
|
|
43
|
+
*/
|
|
44
|
+
function randomString(byteLength = 32) {
|
|
45
|
+
const buffer = new Uint8Array(byteLength);
|
|
46
|
+
crypto.getRandomValues(buffer);
|
|
47
|
+
return base64url.encode(buffer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/dpop.ts
|
|
52
|
+
/**
|
|
53
|
+
* DPoP (Demonstrating Proof of Possession) verification
|
|
54
|
+
* Implements RFC 9449 using jose library for JWT operations
|
|
55
|
+
*/
|
|
56
|
+
const { JOSEError } = errors;
|
|
57
|
+
/**
|
|
58
|
+
* DPoP verification error
|
|
59
|
+
*/
|
|
60
|
+
var DpopError = class extends Error {
|
|
61
|
+
code;
|
|
62
|
+
constructor(message, code, options) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
this.name = "DpopError";
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Normalize URI for HTU comparison
|
|
70
|
+
* Removes query string and fragment per RFC 9449
|
|
71
|
+
*/
|
|
72
|
+
function normalizeHtuUrl(url) {
|
|
73
|
+
return url.origin + url.pathname;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse and validate HTU claim
|
|
77
|
+
*/
|
|
78
|
+
function parseHtu(htu) {
|
|
79
|
+
let url;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(htu);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new DpopError("DPoP \"htu\" is not a valid URL", "invalid_dpop");
|
|
84
|
+
}
|
|
85
|
+
if (url.password || url.username) throw new DpopError("DPoP \"htu\" must not contain credentials", "invalid_dpop");
|
|
86
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new DpopError("DPoP \"htu\" must be http or https", "invalid_dpop");
|
|
87
|
+
return normalizeHtuUrl(url);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Verify a DPoP proof from a request
|
|
91
|
+
* Uses jose library for JWT verification
|
|
92
|
+
* @param request The HTTP request containing the DPoP header
|
|
93
|
+
* @param options Verification options
|
|
94
|
+
* @returns The verified proof data
|
|
95
|
+
* @throws DpopError if verification fails
|
|
96
|
+
*/
|
|
97
|
+
async function verifyDpopProof(request, options = {}) {
|
|
98
|
+
const { allowedAlgorithms = ["ES256"], accessToken, expectedNonce, maxTokenAge = 60 } = options;
|
|
99
|
+
const dpopHeader = request.headers.get("DPoP");
|
|
100
|
+
if (!dpopHeader) throw new DpopError("Missing DPoP header", "missing_dpop");
|
|
101
|
+
let protectedHeader;
|
|
102
|
+
let payload;
|
|
103
|
+
try {
|
|
104
|
+
const result = await jwtVerify(dpopHeader, EmbeddedJWK, {
|
|
105
|
+
typ: "dpop+jwt",
|
|
106
|
+
algorithms: allowedAlgorithms,
|
|
107
|
+
maxTokenAge,
|
|
108
|
+
clockTolerance: 10
|
|
109
|
+
});
|
|
110
|
+
protectedHeader = result.protectedHeader;
|
|
111
|
+
payload = result.payload;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof JOSEError) throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", { cause: err });
|
|
114
|
+
throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err });
|
|
115
|
+
}
|
|
116
|
+
if (!payload.jti || typeof payload.jti !== "string") throw new DpopError("DPoP \"jti\" missing", "invalid_dpop");
|
|
117
|
+
if (!payload.htm || typeof payload.htm !== "string") throw new DpopError("DPoP \"htm\" missing", "invalid_dpop");
|
|
118
|
+
if (!payload.htu || typeof payload.htu !== "string") throw new DpopError("DPoP \"htu\" missing", "invalid_dpop");
|
|
119
|
+
if (payload.htm !== request.method) throw new DpopError("DPoP \"htm\" mismatch", "invalid_dpop");
|
|
120
|
+
const expectedHtu = normalizeHtuUrl(new URL(request.url));
|
|
121
|
+
if (parseHtu(payload.htu) !== expectedHtu) throw new DpopError("DPoP \"htu\" mismatch", "invalid_dpop");
|
|
122
|
+
if (expectedNonce !== void 0 && payload.nonce !== expectedNonce) throw new DpopError("DPoP \"nonce\" mismatch", "use_dpop_nonce");
|
|
123
|
+
if (accessToken) {
|
|
124
|
+
if (!payload.ath) throw new DpopError("DPoP \"ath\" missing when access token provided", "invalid_dpop");
|
|
125
|
+
const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
126
|
+
const expectedAth = base64url.encode(new Uint8Array(tokenHash));
|
|
127
|
+
if (payload.ath !== expectedAth) throw new DpopError("DPoP \"ath\" mismatch", "invalid_dpop");
|
|
128
|
+
} else if (payload.ath !== void 0) throw new DpopError("DPoP \"ath\" claim not allowed without access token", "invalid_dpop");
|
|
129
|
+
const jwk = protectedHeader.jwk;
|
|
130
|
+
const jkt = await calculateJwkThumbprint(jwk, "sha256");
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
htm: payload.htm,
|
|
133
|
+
htu: payload.htu,
|
|
134
|
+
jti: payload.jti,
|
|
135
|
+
ath: payload.ath,
|
|
136
|
+
jkt,
|
|
137
|
+
jwk
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Generate a random DPoP nonce
|
|
142
|
+
* @returns A base64url-encoded random nonce (16 bytes)
|
|
143
|
+
*/
|
|
144
|
+
function generateDpopNonce() {
|
|
145
|
+
return randomString(16);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/par.ts
|
|
150
|
+
/** PAR request URI prefix per RFC 9126 */
|
|
151
|
+
const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
|
|
152
|
+
/** Default PAR expiration in seconds (90 seconds per RFC recommendation) */
|
|
153
|
+
const DEFAULT_EXPIRES_IN = 90;
|
|
154
|
+
/**
|
|
155
|
+
* Generate a unique request URI
|
|
156
|
+
*/
|
|
157
|
+
function generateRequestUri() {
|
|
158
|
+
return REQUEST_URI_PREFIX + randomString(32);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Required OAuth parameters for authorization request
|
|
162
|
+
*/
|
|
163
|
+
const REQUIRED_PARAMS = [
|
|
164
|
+
"client_id",
|
|
165
|
+
"redirect_uri",
|
|
166
|
+
"response_type",
|
|
167
|
+
"code_challenge",
|
|
168
|
+
"code_challenge_method",
|
|
169
|
+
"state"
|
|
170
|
+
];
|
|
171
|
+
/**
|
|
172
|
+
* Handler for Pushed Authorization Requests (PAR)
|
|
173
|
+
*/
|
|
174
|
+
var PARHandler = class {
|
|
175
|
+
storage;
|
|
176
|
+
issuer;
|
|
177
|
+
expiresIn;
|
|
178
|
+
/**
|
|
179
|
+
* Create a PAR handler
|
|
180
|
+
* @param storage OAuth storage implementation
|
|
181
|
+
* @param issuer The OAuth issuer URL
|
|
182
|
+
* @param expiresIn PAR expiration time in seconds (default: 90)
|
|
183
|
+
*/
|
|
184
|
+
constructor(storage, issuer, expiresIn = DEFAULT_EXPIRES_IN) {
|
|
185
|
+
this.storage = storage;
|
|
186
|
+
this.issuer = issuer;
|
|
187
|
+
this.expiresIn = expiresIn;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Handle a PAR push request
|
|
191
|
+
* POST /oauth/par
|
|
192
|
+
* @param request The HTTP request
|
|
193
|
+
* @returns Response with request_uri or error
|
|
194
|
+
*/
|
|
195
|
+
async handlePushRequest(request) {
|
|
196
|
+
let params;
|
|
197
|
+
try {
|
|
198
|
+
params = await parseRequestBody(request);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return this.errorResponse("invalid_request", e instanceof Error ? e.message : "Invalid request", 400);
|
|
201
|
+
}
|
|
202
|
+
const clientId = params.client_id;
|
|
203
|
+
if (!clientId) return this.errorResponse("invalid_request", "Missing client_id parameter", 400);
|
|
204
|
+
for (const param of REQUIRED_PARAMS) if (!params[param]) return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400);
|
|
205
|
+
if (params.response_type !== "code") return this.errorResponse("unsupported_response_type", "Only response_type=code is supported", 400);
|
|
206
|
+
if (params.code_challenge_method !== "S256") return this.errorResponse("invalid_request", "Only code_challenge_method=S256 is supported", 400);
|
|
207
|
+
const codeChallenge = params.code_challenge;
|
|
208
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) return this.errorResponse("invalid_request", "Invalid code_challenge format", 400);
|
|
209
|
+
try {
|
|
210
|
+
new URL(params.redirect_uri);
|
|
211
|
+
} catch {
|
|
212
|
+
return this.errorResponse("invalid_request", "Invalid redirect_uri", 400);
|
|
213
|
+
}
|
|
214
|
+
const requestUri = generateRequestUri();
|
|
215
|
+
const expiresAt = Date.now() + this.expiresIn * 1e3;
|
|
216
|
+
const parData = {
|
|
217
|
+
clientId,
|
|
218
|
+
params,
|
|
219
|
+
expiresAt
|
|
220
|
+
};
|
|
221
|
+
await this.storage.savePAR(requestUri, parData);
|
|
222
|
+
const response = {
|
|
223
|
+
request_uri: requestUri,
|
|
224
|
+
expires_in: this.expiresIn
|
|
225
|
+
};
|
|
226
|
+
return new Response(JSON.stringify(response), {
|
|
227
|
+
status: 201,
|
|
228
|
+
headers: {
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
"Cache-Control": "no-store"
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Retrieve and consume PAR parameters
|
|
236
|
+
* Called during authorization request handling
|
|
237
|
+
* @param requestUri The request URI from the authorization request
|
|
238
|
+
* @param clientId The client_id from the authorization request (for verification)
|
|
239
|
+
* @returns The stored parameters or null if not found/expired
|
|
240
|
+
*/
|
|
241
|
+
async retrieveParams(requestUri, clientId) {
|
|
242
|
+
if (!requestUri.startsWith(REQUEST_URI_PREFIX)) return null;
|
|
243
|
+
const parData = await this.storage.getPAR(requestUri);
|
|
244
|
+
if (!parData) return null;
|
|
245
|
+
if (parData.clientId !== clientId) return null;
|
|
246
|
+
await this.storage.deletePAR(requestUri);
|
|
247
|
+
return parData.params;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Check if a request_uri is valid format
|
|
251
|
+
*/
|
|
252
|
+
static isRequestUri(value) {
|
|
253
|
+
return value.startsWith(REQUEST_URI_PREFIX);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Create an OAuth error response
|
|
257
|
+
*/
|
|
258
|
+
errorResponse(error, description, status = 400) {
|
|
259
|
+
const body = {
|
|
260
|
+
error,
|
|
261
|
+
error_description: description
|
|
262
|
+
};
|
|
263
|
+
return new Response(JSON.stringify(body), {
|
|
264
|
+
status,
|
|
265
|
+
headers: {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
"Cache-Control": "no-store"
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/client-resolver.ts
|
|
275
|
+
/**
|
|
276
|
+
* Client resolver for DID-based client discovery
|
|
277
|
+
* Resolves OAuth client metadata from DIDs for AT Protocol
|
|
278
|
+
*/
|
|
279
|
+
/**
|
|
280
|
+
* Client resolution error
|
|
281
|
+
*/
|
|
282
|
+
var ClientResolutionError = class extends Error {
|
|
283
|
+
constructor(message, code) {
|
|
284
|
+
super(message);
|
|
285
|
+
this.code = code;
|
|
286
|
+
this.name = "ClientResolutionError";
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* Check if a string is a valid HTTPS URL
|
|
291
|
+
*/
|
|
292
|
+
function isHttpsUrl(value) {
|
|
293
|
+
try {
|
|
294
|
+
return new URL(value).protocol === "https:";
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Validate that a string is a valid DID using @atproto/syntax
|
|
301
|
+
*/
|
|
302
|
+
function isValidDid(value) {
|
|
303
|
+
try {
|
|
304
|
+
ensureValidDid(value);
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Get the client metadata URL from a client ID
|
|
312
|
+
* Supports both URL-based and DID-based client IDs
|
|
313
|
+
*/
|
|
314
|
+
function getClientMetadataUrl(clientId) {
|
|
315
|
+
if (isHttpsUrl(clientId)) return clientId;
|
|
316
|
+
if (clientId.startsWith("did:web:")) {
|
|
317
|
+
const parts = clientId.slice(8).split(":");
|
|
318
|
+
const host = parts[0].replace(/%3A/g, ":");
|
|
319
|
+
const path = parts.slice(1).join("/");
|
|
320
|
+
return `${`https://${host}${path ? "/" + path : ""}`}/.well-known/oauth-client-metadata`;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Resolve client metadata from a DID
|
|
326
|
+
*/
|
|
327
|
+
var ClientResolver = class {
|
|
328
|
+
storage;
|
|
329
|
+
cacheTtl;
|
|
330
|
+
fetchFn;
|
|
331
|
+
constructor(options = {}) {
|
|
332
|
+
this.storage = options.storage;
|
|
333
|
+
this.cacheTtl = options.cacheTtl ?? 3600 * 1e3;
|
|
334
|
+
this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Resolve client metadata from a client ID (URL or DID)
|
|
338
|
+
* @param clientId The client ID (HTTPS URL or DID)
|
|
339
|
+
* @returns The client metadata
|
|
340
|
+
* @throws ClientResolutionError if resolution fails
|
|
341
|
+
*/
|
|
342
|
+
async resolveClient(clientId) {
|
|
343
|
+
if (!isHttpsUrl(clientId) && !isValidDid(clientId)) throw new ClientResolutionError(`Invalid client ID format: ${clientId}`, "invalid_client");
|
|
344
|
+
if (this.storage) {
|
|
345
|
+
const cached = await this.storage.getClient(clientId);
|
|
346
|
+
if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) return cached;
|
|
347
|
+
}
|
|
348
|
+
const metadataUrl = getClientMetadataUrl(clientId);
|
|
349
|
+
if (!metadataUrl) throw new ClientResolutionError(`Unsupported client ID format: ${clientId}`, "invalid_client");
|
|
350
|
+
let response;
|
|
351
|
+
try {
|
|
352
|
+
response = await this.fetchFn(metadataUrl, { headers: { Accept: "application/json" } });
|
|
353
|
+
} catch (e) {
|
|
354
|
+
throw new ClientResolutionError(`Failed to fetch client metadata: ${e}`, "invalid_client");
|
|
355
|
+
}
|
|
356
|
+
if (!response.ok) throw new ClientResolutionError(`Client metadata fetch failed with status ${response.status}`, "invalid_client");
|
|
357
|
+
let doc;
|
|
358
|
+
try {
|
|
359
|
+
const json = await response.json();
|
|
360
|
+
doc = oauthClientMetadataSchema.parse(json);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
throw new ClientResolutionError(`Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`, "invalid_client");
|
|
363
|
+
}
|
|
364
|
+
if (doc.client_id !== clientId) throw new ClientResolutionError(`Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, "invalid_client");
|
|
365
|
+
const metadata = {
|
|
366
|
+
clientId: doc.client_id,
|
|
367
|
+
clientName: doc.client_name ?? clientId,
|
|
368
|
+
redirectUris: doc.redirect_uris,
|
|
369
|
+
logoUri: doc.logo_uri,
|
|
370
|
+
clientUri: doc.client_uri,
|
|
371
|
+
cachedAt: Date.now()
|
|
372
|
+
};
|
|
373
|
+
if (this.storage) await this.storage.saveClient(clientId, metadata);
|
|
374
|
+
return metadata;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Validate that a redirect URI is allowed for a client
|
|
378
|
+
* @param clientId The client DID
|
|
379
|
+
* @param redirectUri The redirect URI to validate
|
|
380
|
+
* @returns true if the redirect URI is allowed
|
|
381
|
+
*/
|
|
382
|
+
async validateRedirectUri(clientId, redirectUri) {
|
|
383
|
+
try {
|
|
384
|
+
return (await this.resolveClient(clientId)).redirectUris.includes(redirectUri);
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* Create a client resolver with optional caching
|
|
392
|
+
*/
|
|
393
|
+
function createClientResolver(options = {}) {
|
|
394
|
+
return new ClientResolver(options);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/tokens.ts
|
|
399
|
+
/** Default access token TTL: 1 hour */
|
|
400
|
+
const ACCESS_TOKEN_TTL = 3600 * 1e3;
|
|
401
|
+
/** Default refresh token TTL: 90 days */
|
|
402
|
+
const REFRESH_TOKEN_TTL = 2160 * 60 * 60 * 1e3;
|
|
403
|
+
/** Authorization code TTL: 5 minutes */
|
|
404
|
+
const AUTH_CODE_TTL = 300 * 1e3;
|
|
405
|
+
/**
|
|
406
|
+
* Generate a cryptographically random token
|
|
407
|
+
* @param bytes Number of random bytes (default: 32)
|
|
408
|
+
* @returns Base64URL-encoded token
|
|
409
|
+
*/
|
|
410
|
+
function generateRandomToken(bytes = 32) {
|
|
411
|
+
return randomString(bytes);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Generate an authorization code
|
|
415
|
+
* @returns A random authorization code
|
|
416
|
+
*/
|
|
417
|
+
function generateAuthCode() {
|
|
418
|
+
return generateRandomToken(32);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Generate access and refresh tokens
|
|
422
|
+
* Tokens are opaque - their meaning comes from the database entry
|
|
423
|
+
* @param options Token generation options
|
|
424
|
+
* @returns Generated tokens and metadata
|
|
425
|
+
*/
|
|
426
|
+
function generateTokens(options) {
|
|
427
|
+
const { sub, clientId, scope, dpopJkt, accessTokenTtl = ACCESS_TOKEN_TTL } = options;
|
|
428
|
+
const accessToken = generateRandomToken(32);
|
|
429
|
+
const refreshToken = generateRandomToken(32);
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const tokenData = {
|
|
432
|
+
accessToken,
|
|
433
|
+
refreshToken,
|
|
434
|
+
clientId,
|
|
435
|
+
sub,
|
|
436
|
+
scope,
|
|
437
|
+
dpopJkt,
|
|
438
|
+
issuedAt: now,
|
|
439
|
+
expiresAt: now + accessTokenTtl,
|
|
440
|
+
revoked: false
|
|
441
|
+
};
|
|
442
|
+
return {
|
|
443
|
+
tokens: {
|
|
444
|
+
accessToken,
|
|
445
|
+
refreshToken,
|
|
446
|
+
tokenType: dpopJkt ? "DPoP" : "Bearer",
|
|
447
|
+
expiresIn: Math.floor(accessTokenTtl / 1e3),
|
|
448
|
+
scope,
|
|
449
|
+
sub
|
|
450
|
+
},
|
|
451
|
+
tokenData
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Refresh tokens - generates new access token, optionally rotates refresh token
|
|
456
|
+
* @param existingData The existing token data
|
|
457
|
+
* @param rotateRefreshToken Whether to generate a new refresh token
|
|
458
|
+
* @param accessTokenTtl Custom access token TTL in ms
|
|
459
|
+
* @returns Updated tokens and token data
|
|
460
|
+
*/
|
|
461
|
+
function refreshTokens(existingData, rotateRefreshToken = false, accessTokenTtl = ACCESS_TOKEN_TTL) {
|
|
462
|
+
const accessToken = generateRandomToken(32);
|
|
463
|
+
const refreshToken = rotateRefreshToken ? generateRandomToken(32) : existingData.refreshToken;
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
const tokenData = {
|
|
466
|
+
...existingData,
|
|
467
|
+
accessToken,
|
|
468
|
+
refreshToken,
|
|
469
|
+
issuedAt: now,
|
|
470
|
+
expiresAt: now + accessTokenTtl
|
|
471
|
+
};
|
|
472
|
+
return {
|
|
473
|
+
tokens: {
|
|
474
|
+
accessToken,
|
|
475
|
+
refreshToken,
|
|
476
|
+
tokenType: existingData.dpopJkt ? "DPoP" : "Bearer",
|
|
477
|
+
expiresIn: Math.floor(accessTokenTtl / 1e3),
|
|
478
|
+
scope: existingData.scope,
|
|
479
|
+
sub: existingData.sub
|
|
480
|
+
},
|
|
481
|
+
tokenData
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Build token response for OAuth token endpoint
|
|
486
|
+
* @param tokens The generated tokens
|
|
487
|
+
* @returns JSON-serializable token response
|
|
488
|
+
*/
|
|
489
|
+
function buildTokenResponse(tokens) {
|
|
490
|
+
return {
|
|
491
|
+
access_token: tokens.accessToken,
|
|
492
|
+
token_type: tokens.tokenType,
|
|
493
|
+
expires_in: tokens.expiresIn,
|
|
494
|
+
refresh_token: tokens.refreshToken,
|
|
495
|
+
scope: tokens.scope,
|
|
496
|
+
sub: tokens.sub
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Extract access token from Authorization header
|
|
501
|
+
* Supports both Bearer and DPoP token types
|
|
502
|
+
* @param request The HTTP request
|
|
503
|
+
* @returns The access token and type, or null if not found
|
|
504
|
+
*/
|
|
505
|
+
function extractAccessToken(request) {
|
|
506
|
+
const authHeader = request.headers.get("Authorization");
|
|
507
|
+
if (!authHeader) return null;
|
|
508
|
+
if (authHeader.startsWith("Bearer ")) return {
|
|
509
|
+
token: authHeader.slice(7),
|
|
510
|
+
type: "Bearer"
|
|
511
|
+
};
|
|
512
|
+
if (authHeader.startsWith("DPoP ")) return {
|
|
513
|
+
token: authHeader.slice(5),
|
|
514
|
+
type: "DPoP"
|
|
515
|
+
};
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Validate that a token is not expired or revoked
|
|
520
|
+
* @param tokenData The token data from storage
|
|
521
|
+
* @returns true if the token is valid
|
|
522
|
+
*/
|
|
523
|
+
function isTokenValid(tokenData) {
|
|
524
|
+
if (tokenData.revoked) return false;
|
|
525
|
+
if (Date.now() > tokenData.expiresAt) return false;
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/ui.ts
|
|
531
|
+
/**
|
|
532
|
+
* Content Security Policy for the consent UI
|
|
533
|
+
*
|
|
534
|
+
* - default-src 'none': Deny all by default
|
|
535
|
+
* - style-src 'unsafe-inline': Allow inline styles (our CSS is inline)
|
|
536
|
+
* - img-src https: data:: Allow images from HTTPS URLs (client logos) and data URIs
|
|
537
|
+
* - form-action 'self': Form can only POST to same origin
|
|
538
|
+
* - frame-ancestors 'none': Prevent clickjacking by disallowing framing
|
|
539
|
+
* - base-uri 'none': Prevent base tag injection
|
|
540
|
+
*/
|
|
541
|
+
const CONSENT_UI_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
542
|
+
/**
|
|
543
|
+
* Escape HTML to prevent XSS
|
|
544
|
+
*/
|
|
545
|
+
function escapeHtml(text) {
|
|
546
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Parse scope string into human-readable descriptions
|
|
550
|
+
*/
|
|
551
|
+
function getScopeDescriptions(scope) {
|
|
552
|
+
const scopes = scope.split(" ").filter(Boolean);
|
|
553
|
+
const descriptions = [];
|
|
554
|
+
for (const s of scopes) switch (s) {
|
|
555
|
+
case "atproto":
|
|
556
|
+
descriptions.push("Access your AT Protocol account");
|
|
557
|
+
break;
|
|
558
|
+
case "transition:generic":
|
|
559
|
+
descriptions.push("Perform account operations");
|
|
560
|
+
break;
|
|
561
|
+
case "transition:chat.bsky":
|
|
562
|
+
descriptions.push("Access chat functionality");
|
|
563
|
+
break;
|
|
564
|
+
default: break;
|
|
565
|
+
}
|
|
566
|
+
if (descriptions.length === 0) descriptions.push("Access your account on your behalf");
|
|
567
|
+
return descriptions;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Render the consent UI HTML
|
|
571
|
+
* @param options Consent UI options
|
|
572
|
+
* @returns HTML string
|
|
573
|
+
*/
|
|
574
|
+
function renderConsentUI(options) {
|
|
575
|
+
const { client, scope, authorizeUrl, oauthParams, userHandle, showLogin, error } = options;
|
|
576
|
+
const clientName = escapeHtml(client.clientName);
|
|
577
|
+
const scopeDescriptions = getScopeDescriptions(scope);
|
|
578
|
+
const logoHtml = client.logoUri ? `<img src="${escapeHtml(client.logoUri)}" alt="${clientName} logo" class="app-logo" />` : `<div class="app-logo-placeholder">${clientName.charAt(0).toUpperCase()}</div>`;
|
|
579
|
+
const errorHtml = error ? `<div class="error-message">${escapeHtml(error)}</div>` : "";
|
|
580
|
+
const loginFormHtml = showLogin ? `
|
|
581
|
+
<div class="login-form">
|
|
582
|
+
<p>Sign in to continue</p>
|
|
583
|
+
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
|
584
|
+
</div>
|
|
585
|
+
` : "";
|
|
586
|
+
const hiddenFieldsHtml = Object.entries(oauthParams).map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`).join("\n ");
|
|
587
|
+
return `<!DOCTYPE html>
|
|
588
|
+
<html lang="en">
|
|
589
|
+
<head>
|
|
590
|
+
<meta charset="UTF-8">
|
|
591
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
592
|
+
<title>Authorize ${clientName}</title>
|
|
593
|
+
<style>
|
|
594
|
+
* {
|
|
595
|
+
box-sizing: border-box;
|
|
596
|
+
margin: 0;
|
|
597
|
+
padding: 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
body {
|
|
601
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
602
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
603
|
+
min-height: 100vh;
|
|
604
|
+
display: flex;
|
|
605
|
+
align-items: center;
|
|
606
|
+
justify-content: center;
|
|
607
|
+
padding: 20px;
|
|
608
|
+
color: #e0e0e0;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.container {
|
|
612
|
+
background: #1e1e30;
|
|
613
|
+
border-radius: 16px;
|
|
614
|
+
padding: 32px;
|
|
615
|
+
max-width: 400px;
|
|
616
|
+
width: 100%;
|
|
617
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
618
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.header {
|
|
622
|
+
text-align: center;
|
|
623
|
+
margin-bottom: 24px;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.app-logo {
|
|
627
|
+
width: 64px;
|
|
628
|
+
height: 64px;
|
|
629
|
+
border-radius: 12px;
|
|
630
|
+
margin-bottom: 16px;
|
|
631
|
+
object-fit: cover;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.app-logo-placeholder {
|
|
635
|
+
width: 64px;
|
|
636
|
+
height: 64px;
|
|
637
|
+
border-radius: 12px;
|
|
638
|
+
margin: 0 auto 16px;
|
|
639
|
+
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
|
640
|
+
display: flex;
|
|
641
|
+
align-items: center;
|
|
642
|
+
justify-content: center;
|
|
643
|
+
font-size: 28px;
|
|
644
|
+
font-weight: 600;
|
|
645
|
+
color: white;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
h1 {
|
|
649
|
+
font-size: 20px;
|
|
650
|
+
font-weight: 600;
|
|
651
|
+
margin-bottom: 8px;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.client-name {
|
|
655
|
+
color: #60a5fa;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.user-info {
|
|
659
|
+
font-size: 14px;
|
|
660
|
+
color: #9ca3af;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.permissions {
|
|
664
|
+
background: rgba(255, 255, 255, 0.05);
|
|
665
|
+
border-radius: 12px;
|
|
666
|
+
padding: 16px;
|
|
667
|
+
margin-bottom: 24px;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.permissions-title {
|
|
671
|
+
font-size: 14px;
|
|
672
|
+
color: #9ca3af;
|
|
673
|
+
margin-bottom: 12px;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.permissions-list {
|
|
677
|
+
list-style: none;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.permissions-list li {
|
|
681
|
+
display: flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
gap: 10px;
|
|
684
|
+
padding: 8px 0;
|
|
685
|
+
font-size: 14px;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.permissions-list li::before {
|
|
689
|
+
content: "";
|
|
690
|
+
width: 8px;
|
|
691
|
+
height: 8px;
|
|
692
|
+
background: #22c55e;
|
|
693
|
+
border-radius: 50%;
|
|
694
|
+
flex-shrink: 0;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.buttons {
|
|
698
|
+
display: flex;
|
|
699
|
+
gap: 12px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
button {
|
|
703
|
+
flex: 1;
|
|
704
|
+
padding: 12px 20px;
|
|
705
|
+
border-radius: 8px;
|
|
706
|
+
font-size: 14px;
|
|
707
|
+
font-weight: 500;
|
|
708
|
+
cursor: pointer;
|
|
709
|
+
transition: all 0.2s;
|
|
710
|
+
border: none;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.btn-deny {
|
|
714
|
+
background: rgba(255, 255, 255, 0.1);
|
|
715
|
+
color: #e0e0e0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.btn-deny:hover {
|
|
719
|
+
background: rgba(255, 255, 255, 0.15);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.btn-allow {
|
|
723
|
+
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
|
724
|
+
color: white;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.btn-allow:hover {
|
|
728
|
+
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.info {
|
|
732
|
+
margin-top: 16px;
|
|
733
|
+
font-size: 12px;
|
|
734
|
+
color: #6b7280;
|
|
735
|
+
text-align: center;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.error-message {
|
|
739
|
+
background: rgba(239, 68, 68, 0.1);
|
|
740
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
741
|
+
color: #f87171;
|
|
742
|
+
padding: 12px;
|
|
743
|
+
border-radius: 8px;
|
|
744
|
+
margin-bottom: 16px;
|
|
745
|
+
font-size: 14px;
|
|
746
|
+
text-align: center;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.login-form {
|
|
750
|
+
margin-bottom: 24px;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.login-form p {
|
|
754
|
+
font-size: 14px;
|
|
755
|
+
color: #9ca3af;
|
|
756
|
+
margin-bottom: 12px;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
.login-form input {
|
|
760
|
+
width: 100%;
|
|
761
|
+
padding: 12px;
|
|
762
|
+
border-radius: 8px;
|
|
763
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
764
|
+
background: rgba(255, 255, 255, 0.05);
|
|
765
|
+
color: #e0e0e0;
|
|
766
|
+
font-size: 14px;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.login-form input:focus {
|
|
770
|
+
outline: none;
|
|
771
|
+
border-color: #3b82f6;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.login-form input::placeholder {
|
|
775
|
+
color: #6b7280;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.client-uri {
|
|
779
|
+
font-size: 12px;
|
|
780
|
+
color: #6b7280;
|
|
781
|
+
margin-top: 4px;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.client-uri a {
|
|
785
|
+
color: #60a5fa;
|
|
786
|
+
text-decoration: none;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.client-uri a:hover {
|
|
790
|
+
text-decoration: underline;
|
|
791
|
+
}
|
|
792
|
+
</style>
|
|
793
|
+
</head>
|
|
794
|
+
<body>
|
|
795
|
+
<div class="container">
|
|
796
|
+
<div class="header">
|
|
797
|
+
${logoHtml}
|
|
798
|
+
<h1>Authorize <span class="client-name">${clientName}</span></h1>
|
|
799
|
+
${userHandle ? `<p class="user-info">as @${escapeHtml(userHandle)}</p>` : ""}
|
|
800
|
+
${client.clientUri ? `<p class="client-uri"><a href="${escapeHtml(client.clientUri)}" target="_blank" rel="noopener">${escapeHtml(new URL(client.clientUri).hostname)}</a></p>` : ""}
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
${errorHtml}
|
|
804
|
+
|
|
805
|
+
<form method="POST" action="${escapeHtml(authorizeUrl)}">
|
|
806
|
+
${hiddenFieldsHtml}
|
|
807
|
+
|
|
808
|
+
${loginFormHtml}
|
|
809
|
+
|
|
810
|
+
<div class="permissions">
|
|
811
|
+
<p class="permissions-title">This app wants to:</p>
|
|
812
|
+
<ul class="permissions-list">
|
|
813
|
+
${scopeDescriptions.map((desc) => `<li>${escapeHtml(desc)}</li>`).join("")}
|
|
814
|
+
</ul>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<div class="buttons">
|
|
818
|
+
<button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
|
|
819
|
+
<button type="submit" name="action" value="allow" class="btn-allow">Allow</button>
|
|
820
|
+
</div>
|
|
821
|
+
</form>
|
|
822
|
+
|
|
823
|
+
<p class="info">You can revoke access anytime in your account settings.</p>
|
|
824
|
+
</div>
|
|
825
|
+
</body>
|
|
826
|
+
</html>`;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Render an error page
|
|
830
|
+
* @param error Error code
|
|
831
|
+
* @param description Error description
|
|
832
|
+
* @param redirectUri Optional redirect URI for the error
|
|
833
|
+
* @returns HTML string
|
|
834
|
+
*/
|
|
835
|
+
function renderErrorPage(error, description, redirectUri) {
|
|
836
|
+
const escapedError = escapeHtml(error);
|
|
837
|
+
return `<!DOCTYPE html>
|
|
838
|
+
<html lang="en">
|
|
839
|
+
<head>
|
|
840
|
+
<meta charset="UTF-8">
|
|
841
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
842
|
+
<title>Authorization Error</title>
|
|
843
|
+
<style>
|
|
844
|
+
body {
|
|
845
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
846
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
847
|
+
min-height: 100vh;
|
|
848
|
+
display: flex;
|
|
849
|
+
align-items: center;
|
|
850
|
+
justify-content: center;
|
|
851
|
+
padding: 20px;
|
|
852
|
+
color: #e0e0e0;
|
|
853
|
+
margin: 0;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.container {
|
|
857
|
+
background: #1e1e30;
|
|
858
|
+
border-radius: 16px;
|
|
859
|
+
padding: 32px;
|
|
860
|
+
max-width: 400px;
|
|
861
|
+
width: 100%;
|
|
862
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
863
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
864
|
+
text-align: center;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.error-icon {
|
|
868
|
+
width: 64px;
|
|
869
|
+
height: 64px;
|
|
870
|
+
background: rgba(239, 68, 68, 0.1);
|
|
871
|
+
border-radius: 50%;
|
|
872
|
+
display: flex;
|
|
873
|
+
align-items: center;
|
|
874
|
+
justify-content: center;
|
|
875
|
+
margin: 0 auto 16px;
|
|
876
|
+
font-size: 32px;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
h1 {
|
|
880
|
+
font-size: 20px;
|
|
881
|
+
margin-bottom: 8px;
|
|
882
|
+
color: #f87171;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
p {
|
|
886
|
+
color: #9ca3af;
|
|
887
|
+
font-size: 14px;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
code {
|
|
891
|
+
background: rgba(255, 255, 255, 0.1);
|
|
892
|
+
padding: 2px 6px;
|
|
893
|
+
border-radius: 4px;
|
|
894
|
+
font-size: 12px;
|
|
895
|
+
}
|
|
896
|
+
</style>
|
|
897
|
+
</head>
|
|
898
|
+
<body>
|
|
899
|
+
<div class="container">
|
|
900
|
+
<div class="error-icon">!</div>
|
|
901
|
+
<h1>Authorization Error</h1>
|
|
902
|
+
<p>${escapeHtml(description)}</p>
|
|
903
|
+
<p style="margin-top: 8px;"><code>${escapedError}</code></p>
|
|
904
|
+
${redirectUri ? `<p style="margin-top: 16px;"><a href="${escapeHtml(redirectUri)}" style="color: #60a5fa;">Return to application</a></p>` : ""}
|
|
905
|
+
</div>
|
|
906
|
+
</body>
|
|
907
|
+
</html>`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
//#endregion
|
|
911
|
+
//#region src/provider.ts
|
|
912
|
+
/**
|
|
913
|
+
* OAuth error response builder
|
|
914
|
+
*/
|
|
915
|
+
function oauthError(error, description, status = 400) {
|
|
916
|
+
return new Response(JSON.stringify({
|
|
917
|
+
error,
|
|
918
|
+
error_description: description
|
|
919
|
+
}), {
|
|
920
|
+
status,
|
|
921
|
+
headers: {
|
|
922
|
+
"Content-Type": "application/json",
|
|
923
|
+
"Cache-Control": "no-store"
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Error thrown when request body parsing fails
|
|
929
|
+
*/
|
|
930
|
+
var RequestBodyError = class extends Error {
|
|
931
|
+
constructor(message) {
|
|
932
|
+
super(message);
|
|
933
|
+
this.name = "RequestBodyError";
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* Parse request body from JSON or form-urlencoded
|
|
938
|
+
* @throws RequestBodyError if content type is unsupported or parsing fails
|
|
939
|
+
*/
|
|
940
|
+
async function parseRequestBody(request) {
|
|
941
|
+
const contentType = request.headers.get("Content-Type") ?? "";
|
|
942
|
+
try {
|
|
943
|
+
if (contentType.includes("application/json")) {
|
|
944
|
+
const json = await request.json();
|
|
945
|
+
return Object.fromEntries(Object.entries(json).map(([k, v]) => [k, String(v)]));
|
|
946
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
947
|
+
const body = await request.text();
|
|
948
|
+
return Object.fromEntries(new URLSearchParams(body).entries());
|
|
949
|
+
} else throw new RequestBodyError("Content-Type must be application/json or application/x-www-form-urlencoded");
|
|
950
|
+
} catch (e) {
|
|
951
|
+
if (e instanceof RequestBodyError) throw e;
|
|
952
|
+
throw new RequestBodyError("Failed to parse request body");
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* AT Protocol OAuth 2.1 Provider
|
|
957
|
+
*/
|
|
958
|
+
var ATProtoOAuthProvider = class {
|
|
959
|
+
storage;
|
|
960
|
+
issuer;
|
|
961
|
+
dpopRequired;
|
|
962
|
+
enablePAR;
|
|
963
|
+
parHandler;
|
|
964
|
+
clientResolver;
|
|
965
|
+
verifyUser;
|
|
966
|
+
getCurrentUser;
|
|
967
|
+
constructor(config) {
|
|
968
|
+
this.storage = config.storage;
|
|
969
|
+
this.issuer = config.issuer;
|
|
970
|
+
this.dpopRequired = config.dpopRequired ?? true;
|
|
971
|
+
this.enablePAR = config.enablePAR ?? true;
|
|
972
|
+
this.parHandler = new PARHandler(config.storage, config.issuer);
|
|
973
|
+
this.clientResolver = config.clientResolver ?? new ClientResolver({ storage: config.storage });
|
|
974
|
+
this.verifyUser = config.verifyUser;
|
|
975
|
+
this.getCurrentUser = config.getCurrentUser;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Handle authorization request (GET/POST /oauth/authorize)
|
|
979
|
+
*/
|
|
980
|
+
async handleAuthorize(request) {
|
|
981
|
+
const url = new URL(request.url);
|
|
982
|
+
let params;
|
|
983
|
+
if (request.method === "POST") {
|
|
984
|
+
const formData = await request.formData();
|
|
985
|
+
params = {};
|
|
986
|
+
for (const [key, value] of formData.entries()) if (typeof value === "string") params[key] = value;
|
|
987
|
+
} else {
|
|
988
|
+
const requestUri = url.searchParams.get("request_uri");
|
|
989
|
+
const clientId = url.searchParams.get("client_id");
|
|
990
|
+
if (requestUri && this.enablePAR) {
|
|
991
|
+
if (!clientId) return this.renderError("invalid_request", "client_id required with request_uri");
|
|
992
|
+
const parParams = await this.parHandler.retrieveParams(requestUri, clientId);
|
|
993
|
+
if (!parParams) return this.renderError("invalid_request", "Invalid or expired request_uri");
|
|
994
|
+
params = parParams;
|
|
995
|
+
} else params = Object.fromEntries(url.searchParams.entries());
|
|
996
|
+
}
|
|
997
|
+
for (const param of [
|
|
998
|
+
"client_id",
|
|
999
|
+
"redirect_uri",
|
|
1000
|
+
"response_type",
|
|
1001
|
+
"code_challenge",
|
|
1002
|
+
"state"
|
|
1003
|
+
]) if (!params[param]) return this.renderError("invalid_request", `Missing required parameter: ${param}`);
|
|
1004
|
+
if (params.response_type !== "code") return this.renderError("unsupported_response_type", "Only response_type=code is supported");
|
|
1005
|
+
if (params.code_challenge_method && params.code_challenge_method !== "S256") return this.renderError("invalid_request", "Only code_challenge_method=S256 is supported");
|
|
1006
|
+
let client;
|
|
1007
|
+
try {
|
|
1008
|
+
client = await this.clientResolver.resolveClient(params.client_id);
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
return this.renderError("invalid_client", `Failed to resolve client: ${e}`);
|
|
1011
|
+
}
|
|
1012
|
+
if (!client.redirectUris.includes(params.redirect_uri)) return this.renderError("invalid_request", "Invalid redirect_uri for this client");
|
|
1013
|
+
if (request.method === "POST") return this.handleAuthorizePost(request, params, client);
|
|
1014
|
+
let user = null;
|
|
1015
|
+
if (this.getCurrentUser) user = await this.getCurrentUser();
|
|
1016
|
+
const scope = params.scope ?? "atproto";
|
|
1017
|
+
const html = renderConsentUI({
|
|
1018
|
+
client,
|
|
1019
|
+
scope,
|
|
1020
|
+
authorizeUrl: url.pathname,
|
|
1021
|
+
state: params.state,
|
|
1022
|
+
oauthParams: params,
|
|
1023
|
+
userHandle: user?.handle,
|
|
1024
|
+
showLogin: !user && !!this.verifyUser
|
|
1025
|
+
});
|
|
1026
|
+
return new Response(html, {
|
|
1027
|
+
status: 200,
|
|
1028
|
+
headers: {
|
|
1029
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1030
|
+
"Content-Security-Policy": CONSENT_UI_CSP,
|
|
1031
|
+
"Cache-Control": "no-store"
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Handle authorization form POST
|
|
1037
|
+
*/
|
|
1038
|
+
async handleAuthorizePost(request, params, client) {
|
|
1039
|
+
const action = params.action;
|
|
1040
|
+
const password = params.password ?? null;
|
|
1041
|
+
const redirectUri = params.redirect_uri;
|
|
1042
|
+
const state = params.state;
|
|
1043
|
+
const responseMode = params.response_mode ?? "fragment";
|
|
1044
|
+
if (action === "deny") {
|
|
1045
|
+
const errorUrl = new URL(redirectUri);
|
|
1046
|
+
if (responseMode === "fragment") {
|
|
1047
|
+
const hashParams = new URLSearchParams();
|
|
1048
|
+
hashParams.set("error", "access_denied");
|
|
1049
|
+
hashParams.set("error_description", "User denied authorization");
|
|
1050
|
+
hashParams.set("state", state);
|
|
1051
|
+
hashParams.set("iss", this.issuer);
|
|
1052
|
+
errorUrl.hash = hashParams.toString();
|
|
1053
|
+
} else {
|
|
1054
|
+
errorUrl.searchParams.set("error", "access_denied");
|
|
1055
|
+
errorUrl.searchParams.set("error_description", "User denied authorization");
|
|
1056
|
+
errorUrl.searchParams.set("state", state);
|
|
1057
|
+
errorUrl.searchParams.set("iss", this.issuer);
|
|
1058
|
+
}
|
|
1059
|
+
return Response.redirect(errorUrl.toString(), 302);
|
|
1060
|
+
}
|
|
1061
|
+
let user = null;
|
|
1062
|
+
if (this.getCurrentUser) user = await this.getCurrentUser();
|
|
1063
|
+
if (!user && password && this.verifyUser) user = await this.verifyUser(password);
|
|
1064
|
+
if (!user) {
|
|
1065
|
+
const url = new URL(request.url);
|
|
1066
|
+
const html = renderConsentUI({
|
|
1067
|
+
client,
|
|
1068
|
+
scope: params.scope ?? "atproto",
|
|
1069
|
+
authorizeUrl: url.pathname,
|
|
1070
|
+
state,
|
|
1071
|
+
oauthParams: params,
|
|
1072
|
+
showLogin: true,
|
|
1073
|
+
error: "Invalid password"
|
|
1074
|
+
});
|
|
1075
|
+
return new Response(html, {
|
|
1076
|
+
status: 401,
|
|
1077
|
+
headers: {
|
|
1078
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1079
|
+
"Content-Security-Policy": CONSENT_UI_CSP,
|
|
1080
|
+
"Cache-Control": "no-store"
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const code = generateAuthCode();
|
|
1085
|
+
const scope = params.scope ?? "atproto";
|
|
1086
|
+
const authCodeData = {
|
|
1087
|
+
clientId: params.client_id,
|
|
1088
|
+
redirectUri,
|
|
1089
|
+
codeChallenge: params.code_challenge,
|
|
1090
|
+
codeChallengeMethod: "S256",
|
|
1091
|
+
scope,
|
|
1092
|
+
sub: user.sub,
|
|
1093
|
+
expiresAt: Date.now() + AUTH_CODE_TTL
|
|
1094
|
+
};
|
|
1095
|
+
await this.storage.saveAuthCode(code, authCodeData);
|
|
1096
|
+
const successUrl = new URL(redirectUri);
|
|
1097
|
+
if (responseMode === "fragment") {
|
|
1098
|
+
const hashParams = new URLSearchParams();
|
|
1099
|
+
hashParams.set("code", code);
|
|
1100
|
+
hashParams.set("state", state);
|
|
1101
|
+
hashParams.set("iss", this.issuer);
|
|
1102
|
+
successUrl.hash = hashParams.toString();
|
|
1103
|
+
} else {
|
|
1104
|
+
successUrl.searchParams.set("code", code);
|
|
1105
|
+
successUrl.searchParams.set("state", state);
|
|
1106
|
+
successUrl.searchParams.set("iss", this.issuer);
|
|
1107
|
+
}
|
|
1108
|
+
return Response.redirect(successUrl.toString(), 302);
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Handle token request (POST /oauth/token)
|
|
1112
|
+
*/
|
|
1113
|
+
async handleToken(request) {
|
|
1114
|
+
let params;
|
|
1115
|
+
try {
|
|
1116
|
+
params = await parseRequestBody(request);
|
|
1117
|
+
} catch (e) {
|
|
1118
|
+
return oauthError("invalid_request", e instanceof Error ? e.message : "Invalid request");
|
|
1119
|
+
}
|
|
1120
|
+
const grantType = params.grant_type;
|
|
1121
|
+
if (grantType === "authorization_code") return this.handleAuthorizationCodeGrant(request, params);
|
|
1122
|
+
else if (grantType === "refresh_token") return this.handleRefreshTokenGrant(request, params);
|
|
1123
|
+
else return oauthError("unsupported_grant_type", `Unsupported grant_type: ${grantType}`);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Handle authorization code grant
|
|
1127
|
+
*/
|
|
1128
|
+
async handleAuthorizationCodeGrant(request, params) {
|
|
1129
|
+
for (const param of [
|
|
1130
|
+
"code",
|
|
1131
|
+
"client_id",
|
|
1132
|
+
"redirect_uri",
|
|
1133
|
+
"code_verifier"
|
|
1134
|
+
]) if (!params[param]) return oauthError("invalid_request", `Missing required parameter: ${param}`);
|
|
1135
|
+
const codeData = await this.storage.getAuthCode(params.code);
|
|
1136
|
+
if (!codeData) return oauthError("invalid_grant", "Invalid or expired authorization code");
|
|
1137
|
+
await this.storage.deleteAuthCode(params.code);
|
|
1138
|
+
if (codeData.clientId !== params.client_id) return oauthError("invalid_grant", "client_id mismatch");
|
|
1139
|
+
if (codeData.redirectUri !== params.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
1140
|
+
if (!await verifyPkceChallenge(params.code_verifier, codeData.codeChallenge, codeData.codeChallengeMethod)) return oauthError("invalid_grant", "Invalid code_verifier");
|
|
1141
|
+
let dpopJkt;
|
|
1142
|
+
if (this.dpopRequired) try {
|
|
1143
|
+
const dpopProof = await verifyDpopProof(request);
|
|
1144
|
+
if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
|
|
1145
|
+
dpopJkt = dpopProof.jkt;
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
if (e instanceof DpopError) {
|
|
1148
|
+
if (e.code === "use_dpop_nonce") {
|
|
1149
|
+
const nonce = generateDpopNonce();
|
|
1150
|
+
return new Response(JSON.stringify({
|
|
1151
|
+
error: "use_dpop_nonce",
|
|
1152
|
+
error_description: "DPoP nonce required"
|
|
1153
|
+
}), {
|
|
1154
|
+
status: 400,
|
|
1155
|
+
headers: {
|
|
1156
|
+
"Content-Type": "application/json",
|
|
1157
|
+
"DPoP-Nonce": nonce,
|
|
1158
|
+
"Cache-Control": "no-store"
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
return oauthError("invalid_dpop_proof", e.message);
|
|
1163
|
+
}
|
|
1164
|
+
return oauthError("invalid_dpop_proof", "DPoP verification failed");
|
|
1165
|
+
}
|
|
1166
|
+
else if (request.headers.get("DPoP")) try {
|
|
1167
|
+
const dpopProof = await verifyDpopProof(request);
|
|
1168
|
+
if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
|
|
1169
|
+
dpopJkt = dpopProof.jkt;
|
|
1170
|
+
} catch (e) {
|
|
1171
|
+
if (e instanceof DpopError) return oauthError("invalid_dpop_proof", e.message);
|
|
1172
|
+
return oauthError("invalid_dpop_proof", "DPoP verification failed");
|
|
1173
|
+
}
|
|
1174
|
+
const { tokens, tokenData } = generateTokens({
|
|
1175
|
+
sub: codeData.sub,
|
|
1176
|
+
clientId: codeData.clientId,
|
|
1177
|
+
scope: codeData.scope,
|
|
1178
|
+
dpopJkt
|
|
1179
|
+
});
|
|
1180
|
+
await this.storage.saveTokens(tokenData);
|
|
1181
|
+
return new Response(JSON.stringify(buildTokenResponse(tokens)), {
|
|
1182
|
+
status: 200,
|
|
1183
|
+
headers: {
|
|
1184
|
+
"Content-Type": "application/json",
|
|
1185
|
+
"Cache-Control": "no-store"
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Handle refresh token grant
|
|
1191
|
+
*/
|
|
1192
|
+
async handleRefreshTokenGrant(request, params) {
|
|
1193
|
+
const refreshToken = params.refresh_token;
|
|
1194
|
+
if (!refreshToken) return oauthError("invalid_request", "Missing refresh_token parameter");
|
|
1195
|
+
const existingData = await this.storage.getTokenByRefresh(refreshToken);
|
|
1196
|
+
if (!existingData) return oauthError("invalid_grant", "Invalid refresh token");
|
|
1197
|
+
if (existingData.revoked) return oauthError("invalid_grant", "Token has been revoked");
|
|
1198
|
+
if (params.client_id && params.client_id !== existingData.clientId) return oauthError("invalid_grant", "client_id mismatch");
|
|
1199
|
+
if (existingData.dpopJkt) try {
|
|
1200
|
+
const dpopProof = await verifyDpopProof(request);
|
|
1201
|
+
if (dpopProof.jkt !== existingData.dpopJkt) return oauthError("invalid_dpop_proof", "DPoP key mismatch");
|
|
1202
|
+
if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return oauthError("invalid_dpop_proof", "DPoP proof replay detected");
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
if (e instanceof DpopError) return oauthError("invalid_dpop_proof", e.message);
|
|
1205
|
+
return oauthError("invalid_dpop_proof", "DPoP verification failed");
|
|
1206
|
+
}
|
|
1207
|
+
await this.storage.revokeToken(existingData.accessToken);
|
|
1208
|
+
const { tokens, tokenData } = refreshTokens(existingData, true);
|
|
1209
|
+
await this.storage.saveTokens(tokenData);
|
|
1210
|
+
return new Response(JSON.stringify(buildTokenResponse(tokens)), {
|
|
1211
|
+
status: 200,
|
|
1212
|
+
headers: {
|
|
1213
|
+
"Content-Type": "application/json",
|
|
1214
|
+
"Cache-Control": "no-store"
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Handle PAR request (POST /oauth/par)
|
|
1220
|
+
*/
|
|
1221
|
+
async handlePAR(request) {
|
|
1222
|
+
if (!this.enablePAR) return oauthError("invalid_request", "PAR is not enabled");
|
|
1223
|
+
return this.parHandler.handlePushRequest(request);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Handle metadata request (GET /.well-known/oauth-authorization-server)
|
|
1227
|
+
*/
|
|
1228
|
+
handleMetadata() {
|
|
1229
|
+
const metadata = {
|
|
1230
|
+
issuer: this.issuer,
|
|
1231
|
+
authorization_endpoint: `${this.issuer}/oauth/authorize`,
|
|
1232
|
+
token_endpoint: `${this.issuer}/oauth/token`,
|
|
1233
|
+
response_types_supported: ["code"],
|
|
1234
|
+
response_modes_supported: ["fragment", "query"],
|
|
1235
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
1236
|
+
code_challenge_methods_supported: ["S256"],
|
|
1237
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
1238
|
+
scopes_supported: [
|
|
1239
|
+
"atproto",
|
|
1240
|
+
"transition:generic",
|
|
1241
|
+
"transition:chat.bsky"
|
|
1242
|
+
],
|
|
1243
|
+
subject_types_supported: ["public"],
|
|
1244
|
+
authorization_response_iss_parameter_supported: true,
|
|
1245
|
+
client_id_metadata_document_supported: true,
|
|
1246
|
+
...this.enablePAR && {
|
|
1247
|
+
pushed_authorization_request_endpoint: `${this.issuer}/oauth/par`,
|
|
1248
|
+
require_pushed_authorization_requests: false
|
|
1249
|
+
},
|
|
1250
|
+
...this.dpopRequired && {
|
|
1251
|
+
dpop_signing_alg_values_supported: ["ES256"],
|
|
1252
|
+
token_endpoint_auth_signing_alg_values_supported: ["ES256"]
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
return new Response(JSON.stringify(metadata), {
|
|
1256
|
+
status: 200,
|
|
1257
|
+
headers: {
|
|
1258
|
+
"Content-Type": "application/json",
|
|
1259
|
+
"Cache-Control": "max-age=3600"
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Verify an access token from a request
|
|
1265
|
+
* @param request The HTTP request
|
|
1266
|
+
* @param requiredScope Optional scope to require
|
|
1267
|
+
* @returns Token data if valid
|
|
1268
|
+
*/
|
|
1269
|
+
async verifyAccessToken(request, requiredScope) {
|
|
1270
|
+
const tokenInfo = extractAccessToken(request);
|
|
1271
|
+
if (!tokenInfo) return null;
|
|
1272
|
+
const tokenData = await this.storage.getTokenByAccess(tokenInfo.token);
|
|
1273
|
+
if (!tokenData) return null;
|
|
1274
|
+
if (!isTokenValid(tokenData)) return null;
|
|
1275
|
+
if (tokenData.dpopJkt && tokenInfo.type !== "DPoP") return null;
|
|
1276
|
+
if (tokenData.dpopJkt) try {
|
|
1277
|
+
const dpopProof = await verifyDpopProof(request, { accessToken: tokenInfo.token });
|
|
1278
|
+
if (dpopProof.jkt !== tokenData.dpopJkt) return null;
|
|
1279
|
+
if (!await this.storage.checkAndSaveNonce(dpopProof.jti)) return null;
|
|
1280
|
+
} catch {
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
if (requiredScope) {
|
|
1284
|
+
if (!tokenData.scope.split(" ").includes(requiredScope)) return null;
|
|
1285
|
+
}
|
|
1286
|
+
return tokenData;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Render an error page
|
|
1290
|
+
*/
|
|
1291
|
+
renderError(error, description) {
|
|
1292
|
+
const html = renderErrorPage(error, description);
|
|
1293
|
+
return new Response(html, {
|
|
1294
|
+
status: 400,
|
|
1295
|
+
headers: {
|
|
1296
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1297
|
+
"Content-Security-Policy": CONSENT_UI_CSP,
|
|
1298
|
+
"Cache-Control": "no-store"
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/storage.ts
|
|
1306
|
+
/**
|
|
1307
|
+
* In-memory storage implementation for testing
|
|
1308
|
+
*/
|
|
1309
|
+
var InMemoryOAuthStorage = class {
|
|
1310
|
+
authCodes = /* @__PURE__ */ new Map();
|
|
1311
|
+
tokens = /* @__PURE__ */ new Map();
|
|
1312
|
+
refreshTokenIndex = /* @__PURE__ */ new Map();
|
|
1313
|
+
clients = /* @__PURE__ */ new Map();
|
|
1314
|
+
parRequests = /* @__PURE__ */ new Map();
|
|
1315
|
+
nonces = /* @__PURE__ */ new Set();
|
|
1316
|
+
async saveAuthCode(code, data) {
|
|
1317
|
+
this.authCodes.set(code, data);
|
|
1318
|
+
}
|
|
1319
|
+
async getAuthCode(code) {
|
|
1320
|
+
const data = this.authCodes.get(code);
|
|
1321
|
+
if (!data) return null;
|
|
1322
|
+
if (Date.now() > data.expiresAt) {
|
|
1323
|
+
this.authCodes.delete(code);
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
return data;
|
|
1327
|
+
}
|
|
1328
|
+
async deleteAuthCode(code) {
|
|
1329
|
+
this.authCodes.delete(code);
|
|
1330
|
+
}
|
|
1331
|
+
async saveTokens(data) {
|
|
1332
|
+
this.tokens.set(data.accessToken, data);
|
|
1333
|
+
this.refreshTokenIndex.set(data.refreshToken, data.accessToken);
|
|
1334
|
+
}
|
|
1335
|
+
async getTokenByAccess(accessToken) {
|
|
1336
|
+
const data = this.tokens.get(accessToken);
|
|
1337
|
+
if (!data) return null;
|
|
1338
|
+
if (data.revoked || Date.now() > data.expiresAt) return null;
|
|
1339
|
+
return data;
|
|
1340
|
+
}
|
|
1341
|
+
async getTokenByRefresh(refreshToken) {
|
|
1342
|
+
const accessToken = this.refreshTokenIndex.get(refreshToken);
|
|
1343
|
+
if (!accessToken) return null;
|
|
1344
|
+
const data = this.tokens.get(accessToken);
|
|
1345
|
+
if (!data) return null;
|
|
1346
|
+
if (data.revoked) return null;
|
|
1347
|
+
return data;
|
|
1348
|
+
}
|
|
1349
|
+
async revokeToken(accessToken) {
|
|
1350
|
+
const data = this.tokens.get(accessToken);
|
|
1351
|
+
if (data) data.revoked = true;
|
|
1352
|
+
}
|
|
1353
|
+
async revokeAllTokens(sub) {
|
|
1354
|
+
for (const [, data] of this.tokens) if (data.sub === sub) data.revoked = true;
|
|
1355
|
+
}
|
|
1356
|
+
async saveClient(clientId, metadata) {
|
|
1357
|
+
this.clients.set(clientId, metadata);
|
|
1358
|
+
}
|
|
1359
|
+
async getClient(clientId) {
|
|
1360
|
+
return this.clients.get(clientId) ?? null;
|
|
1361
|
+
}
|
|
1362
|
+
async savePAR(requestUri, data) {
|
|
1363
|
+
this.parRequests.set(requestUri, data);
|
|
1364
|
+
}
|
|
1365
|
+
async getPAR(requestUri) {
|
|
1366
|
+
const data = this.parRequests.get(requestUri);
|
|
1367
|
+
if (!data) return null;
|
|
1368
|
+
if (Date.now() > data.expiresAt) {
|
|
1369
|
+
this.parRequests.delete(requestUri);
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
return data;
|
|
1373
|
+
}
|
|
1374
|
+
async deletePAR(requestUri) {
|
|
1375
|
+
this.parRequests.delete(requestUri);
|
|
1376
|
+
}
|
|
1377
|
+
async checkAndSaveNonce(nonce) {
|
|
1378
|
+
if (this.nonces.has(nonce)) return false;
|
|
1379
|
+
this.nonces.add(nonce);
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
/** Clear all stored data (for testing) */
|
|
1383
|
+
clear() {
|
|
1384
|
+
this.authCodes.clear();
|
|
1385
|
+
this.tokens.clear();
|
|
1386
|
+
this.refreshTokenIndex.clear();
|
|
1387
|
+
this.clients.clear();
|
|
1388
|
+
this.parRequests.clear();
|
|
1389
|
+
this.nonces.clear();
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
//#endregion
|
|
1394
|
+
export { ACCESS_TOKEN_TTL, ATProtoOAuthProvider, AUTH_CODE_TTL, CONSENT_UI_CSP, ClientResolutionError, ClientResolver, DpopError, InMemoryOAuthStorage, PARHandler, REFRESH_TOKEN_TTL, RequestBodyError, buildTokenResponse, createClientResolver, extractAccessToken, generateAuthCode, generateDpopNonce, generateRandomToken, generateTokens, isTokenValid, parseRequestBody, refreshTokens, renderConsentUI, renderErrorPage, verifyDpopProof, verifyPkceChallenge };
|
|
1395
|
+
//# sourceMappingURL=index.js.map
|