@openverifiable/connector-bluesky 1.0.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.
Files changed (64) hide show
  1. package/lib/__fixtures__/did-documents.d.ts +54 -0
  2. package/lib/__fixtures__/did-documents.d.ts.map +1 -0
  3. package/lib/__fixtures__/did-documents.js +56 -0
  4. package/lib/__fixtures__/metadata.d.ts +70 -0
  5. package/lib/__fixtures__/metadata.d.ts.map +1 -0
  6. package/lib/__fixtures__/metadata.js +56 -0
  7. package/lib/__fixtures__/oauth-errors.d.ts +42 -0
  8. package/lib/__fixtures__/oauth-errors.d.ts.map +1 -0
  9. package/lib/__fixtures__/oauth-errors.js +41 -0
  10. package/lib/__fixtures__/profile-responses.d.ts +34 -0
  11. package/lib/__fixtures__/profile-responses.d.ts.map +1 -0
  12. package/lib/__fixtures__/profile-responses.js +28 -0
  13. package/lib/__fixtures__/token-responses.d.ts +38 -0
  14. package/lib/__fixtures__/token-responses.d.ts.map +1 -0
  15. package/lib/__fixtures__/token-responses.js +33 -0
  16. package/lib/__tests__/integration/real-account-helpers.d.ts +43 -0
  17. package/lib/__tests__/integration/real-account-helpers.d.ts.map +1 -0
  18. package/lib/__tests__/integration/real-account-helpers.js +84 -0
  19. package/lib/__tests__/integration/real-account.test.d.ts +12 -0
  20. package/lib/__tests__/integration/real-account.test.d.ts.map +1 -0
  21. package/lib/__tests__/integration/real-account.test.js +118 -0
  22. package/lib/client-assertion.d.ts +19 -0
  23. package/lib/client-assertion.d.ts.map +1 -0
  24. package/lib/client-assertion.js +50 -0
  25. package/lib/client-assertion.test.d.ts +6 -0
  26. package/lib/client-assertion.test.d.ts.map +1 -0
  27. package/lib/client-assertion.test.js +234 -0
  28. package/lib/constant.d.ts +24 -0
  29. package/lib/constant.d.ts.map +1 -0
  30. package/lib/constant.js +77 -0
  31. package/lib/dpop.d.ts +24 -0
  32. package/lib/dpop.d.ts.map +1 -0
  33. package/lib/dpop.js +138 -0
  34. package/lib/dpop.test.d.ts +6 -0
  35. package/lib/dpop.test.d.ts.map +1 -0
  36. package/lib/dpop.test.js +266 -0
  37. package/lib/index.d.ts +12 -0
  38. package/lib/index.d.ts.map +1 -0
  39. package/lib/index.js +1129 -0
  40. package/lib/index.test.d.ts +7 -0
  41. package/lib/index.test.d.ts.map +1 -0
  42. package/lib/index.test.js +329 -0
  43. package/lib/mock.d.ts +5 -0
  44. package/lib/mock.d.ts.map +1 -0
  45. package/lib/mock.js +24 -0
  46. package/lib/pds-discovery.d.ts +18 -0
  47. package/lib/pds-discovery.d.ts.map +1 -0
  48. package/lib/pds-discovery.js +248 -0
  49. package/lib/pds-discovery.test.d.ts +6 -0
  50. package/lib/pds-discovery.test.d.ts.map +1 -0
  51. package/lib/pds-discovery.test.js +281 -0
  52. package/lib/pkce.d.ts +16 -0
  53. package/lib/pkce.d.ts.map +1 -0
  54. package/lib/pkce.js +46 -0
  55. package/lib/pkce.test.d.ts +6 -0
  56. package/lib/pkce.test.d.ts.map +1 -0
  57. package/lib/pkce.test.js +117 -0
  58. package/lib/test-utils.d.ts +65 -0
  59. package/lib/test-utils.d.ts.map +1 -0
  60. package/lib/test-utils.js +132 -0
  61. package/lib/types.d.ts +221 -0
  62. package/lib/types.d.ts.map +1 -0
  63. package/lib/types.js +95 -0
  64. package/package.json +96 -0
package/lib/index.js ADDED
@@ -0,0 +1,1129 @@
1
+ import got, { HTTPError } from 'got';
2
+ import { ConnectorPlatform, ConnectorConfigFormItemType, ConnectorError, ConnectorErrorCodes, ConnectorType, validateConfig, parseJson, jsonGuard } from '@logto/connector-kit';
3
+ import { z } from 'zod';
4
+ import { randomBytes, createHash } from 'crypto';
5
+ import { SignJWT, importJWK } from 'jose';
6
+
7
+ // https://github.com/facebook/jest/issues/7547
8
+ const assert = (value, error) => {
9
+ if (!value) {
10
+ // https://github.com/typescript-eslint/typescript-eslint/issues/3814
11
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
12
+ throw error;
13
+ }
14
+ };
15
+
16
+ /**
17
+ * BlueskyConfig
18
+ * Configuration for AT Protocol OAuth client
19
+ * Requires private_key_jwt for confidential client security
20
+ */
21
+ const blueskyConfigGuard = z.object({
22
+ clientMetadataUri: z.string().url(),
23
+ clientId: z.string().url().optional(),
24
+ jwksUri: z.string().url(),
25
+ scope: z.string().default('atproto transition:generic'),
26
+ tokenEndpointAuthMethod: z.literal('private_key_jwt'),
27
+ }).refine((data) => {
28
+ // JWKS URI is required for private_key_jwt
29
+ if (data.tokenEndpointAuthMethod === 'private_key_jwt' && !data.jwksUri) {
30
+ return false;
31
+ }
32
+ return true;
33
+ }, {
34
+ message: 'jwksUri is required when tokenEndpointAuthMethod is private_key_jwt',
35
+ path: ['jwksUri'],
36
+ });
37
+ /**
38
+ * authResponseGuard
39
+ * Validates query parameters from AT Protocol authorization callback
40
+ */
41
+ const authResponseGuard = z.object({
42
+ code: z.string().optional(),
43
+ state: z.string().optional(),
44
+ iss: z.string().url().optional(),
45
+ error: z.string().optional(),
46
+ error_description: z.string().optional(),
47
+ }).passthrough();
48
+ /**
49
+ * PARResponse
50
+ * Response from Pushed Authorization Request
51
+ */
52
+ z.object({
53
+ request_uri: z.string(),
54
+ expires_in: z.number().optional(),
55
+ });
56
+ /**
57
+ * TokenResponse
58
+ * AT Protocol OAuth token response
59
+ */
60
+ const tokenResponseGuard = z.object({
61
+ access_token: z.string(),
62
+ refresh_token: z.string().optional(),
63
+ token_type: z.literal('Bearer'),
64
+ expires_in: z.number(),
65
+ scope: z.string(),
66
+ sub: z.string(),
67
+ did: z.string().optional(), // Explicit DID field
68
+ });
69
+ /**
70
+ * ProfileResponse
71
+ * AT Protocol profile response from PDS
72
+ */
73
+ const profileResponseGuard = z.object({
74
+ did: z.string(),
75
+ handle: z.string(),
76
+ displayName: z.string().optional(),
77
+ avatar: z.string().optional(),
78
+ email: z.string().optional(),
79
+ description: z.string().optional(),
80
+ });
81
+ /**
82
+ * AuthorizationServerMetadata
83
+ * OAuth authorization server metadata
84
+ */
85
+ const authorizationServerMetadataGuard = z.object({
86
+ issuer: z.string().url(),
87
+ pushed_authorization_request_endpoint: z.string().url(),
88
+ authorization_endpoint: z.string().url(),
89
+ token_endpoint: z.string().url(),
90
+ scopes_supported: z.string(),
91
+ });
92
+ /**
93
+ * ResourceServerMetadata
94
+ * OAuth protected resource metadata
95
+ */
96
+ const resourceServerMetadataGuard = z.object({
97
+ authorization_servers: z.array(z.string().url()),
98
+ });
99
+ /**
100
+ * DIDDocument
101
+ * AT Protocol DID document structure
102
+ */
103
+ const didDocumentGuard = z.object({
104
+ id: z.string(),
105
+ service: z.array(z.object({
106
+ type: z.string(),
107
+ serviceEndpoint: z.string().url(),
108
+ })).optional(),
109
+ });
110
+
111
+ // Connector Metadata
112
+ const defaultMetadata = {
113
+ id: 'bluesky-web',
114
+ target: 'bluesky',
115
+ platform: ConnectorPlatform.Web,
116
+ name: {
117
+ en: 'Bluesky',
118
+ },
119
+ logo: './logo.svg',
120
+ logoDark: './logo-dark.svg',
121
+ description: {
122
+ en: 'Sign in with Bluesky via AT Protocol OAuth. Supports custom PDS instances with PAR, PKCE, and DPoP.',
123
+ },
124
+ readme: './README.md',
125
+ formItems: [
126
+ {
127
+ key: 'clientMetadataUri',
128
+ type: ConnectorConfigFormItemType.Text,
129
+ required: true,
130
+ label: 'Client Metadata URI',
131
+ placeholder: 'https://yourdomain.com/.well-known/oauth-client-metadata.json',
132
+ description: 'Publicly accessible URL to your OAuth client metadata',
133
+ },
134
+ {
135
+ key: 'clientId',
136
+ type: ConnectorConfigFormItemType.Text,
137
+ required: true,
138
+ label: 'Client ID',
139
+ placeholder: 'https://yourdomain.com/client-id',
140
+ description: 'OAuth client identifier (usually same as metadata URI)',
141
+ },
142
+ {
143
+ key: 'jwksUri',
144
+ type: ConnectorConfigFormItemType.Text,
145
+ required: true,
146
+ label: 'JWKS URI',
147
+ placeholder: 'https://yourdomain.com/.well-known/jwks.json',
148
+ description: 'Public key set for client authentication (required for confidential clients with private_key_jwt)',
149
+ },
150
+ {
151
+ key: 'scope',
152
+ type: ConnectorConfigFormItemType.Text,
153
+ required: false,
154
+ label: 'OAuth Scopes',
155
+ placeholder: 'atproto transition:generic',
156
+ defaultValue: 'atproto transition:generic',
157
+ description: 'Space-separated OAuth scopes',
158
+ },
159
+ ],
160
+ };
161
+ // Well-known paths for AT Protocol
162
+ const WELL_KNOWN_PATHS = {
163
+ OAUTH_CLIENT_METADATA: '/.well-known/oauth-client-metadata.json',
164
+ OAUTH_AUTHORIZATION_SERVER: '/.well-known/oauth-authorization-server',
165
+ OAUTH_PROTECTED_RESOURCE: '/.well-known/oauth-protected-resource',
166
+ ATPROTO_DID: '/.well-known/atproto-did',
167
+ };
168
+ // HTTP client settings for SSRF hardening
169
+ const HTTP_CLIENT_CONFIG = {
170
+ timeout: 10000,
171
+ maxRedirects: 3,
172
+ maxResponseSize: 1024 * 1024, // 1MB
173
+ };
174
+ // PKCE settings
175
+ const PKCE_CONFIG = {
176
+ codeVerifierLength: 43,
177
+ codeChallengeMethod: 'S256',
178
+ };
179
+ // DPoP settings
180
+ const DPOP_CONFIG = {
181
+ algorithm: 'ES256',
182
+ keyUsages: ['sign'],
183
+ jtiLength: 16, // Random token length
184
+ };
185
+
186
+ /**
187
+ * Generate a random code verifier for PKCE (RFC 7636)
188
+ * Returns base64url-encoded string of 32-96 random bytes
189
+ */
190
+ function generateCodeVerifier() {
191
+ // Generate 32 random bytes (256 bits) for good security
192
+ const bytes = randomBytes(32);
193
+ // Convert to base64url (URL-safe base64)
194
+ return base64UrlEncode$1(bytes);
195
+ }
196
+ /**
197
+ * Generate code challenge from verifier using S256 method
198
+ * S256 = SHA256(code_verifier) encoded as base64url
199
+ */
200
+ function generateCodeChallenge(verifier) {
201
+ const hash = createHash('sha256');
202
+ hash.update(verifier);
203
+ const digest = hash.digest();
204
+ return base64UrlEncode$1(digest);
205
+ }
206
+ /**
207
+ * Generate PKCE code pair (verifier + challenge)
208
+ */
209
+ function generatePKCECodePair() {
210
+ const codeVerifier = generateCodeVerifier();
211
+ const codeChallenge = generateCodeChallenge(codeVerifier);
212
+ return {
213
+ codeVerifier,
214
+ codeChallenge,
215
+ codeChallengeMethod: PKCE_CONFIG.codeChallengeMethod,
216
+ };
217
+ }
218
+ /**
219
+ * Base64url encode (URL-safe base64)
220
+ * Replaces + with -, / with _, and removes padding =
221
+ */
222
+ function base64UrlEncode$1(buffer) {
223
+ return buffer
224
+ .toString('base64')
225
+ .replace(/\+/g, '-')
226
+ .replace(/\//g, '_')
227
+ .replace(/=/g, '');
228
+ }
229
+
230
+ /**
231
+ * Generate ES256 DPoP key pair
232
+ * Uses Web Crypto API (Node.js crypto.webcrypto)
233
+ */
234
+ async function generateDPoPKeyPair() {
235
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
236
+ const keyPair = await crypto.subtle.generateKey({
237
+ name: 'ECDSA',
238
+ namedCurve: 'P-256', // ES256 uses P-256
239
+ }, true, // extractable (needed for server-side storage)
240
+ DPOP_CONFIG.keyUsages);
241
+ return {
242
+ publicKey: keyPair.publicKey,
243
+ privateKey: keyPair.privateKey,
244
+ };
245
+ }
246
+ /**
247
+ * Export public key as JWK for inclusion in DPoP proof header
248
+ */
249
+ async function exportPublicKeyAsJWK(publicKey) {
250
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
251
+ const jwk = await crypto.subtle.exportKey('jwk', publicKey);
252
+ // Remove private key fields if present
253
+ const { d, ...publicJwk } = jwk;
254
+ return publicJwk;
255
+ }
256
+ /**
257
+ * Create DPoP proof JWT for token endpoint requests
258
+ */
259
+ async function createDPoPProofForToken(keyPair, httpMethod, tokenEndpointUrl, nonce) {
260
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
261
+ const publicKeyJwk = await exportPublicKeyAsJWK(keyPair.publicKey);
262
+ // Generate unique jti
263
+ const jti = base64UrlEncode(randomBytes(DPOP_CONFIG.jtiLength));
264
+ const now = Math.floor(Date.now() / 1000);
265
+ const jwt = new SignJWT({
266
+ jti,
267
+ htm: httpMethod,
268
+ htu: tokenEndpointUrl,
269
+ iat: now,
270
+ exp: now + 60,
271
+ ...(nonce && { nonce }),
272
+ })
273
+ .setProtectedHeader({
274
+ typ: 'dpop+jwt',
275
+ alg: DPOP_CONFIG.algorithm,
276
+ jwk: publicKeyJwk,
277
+ });
278
+ // Sign with private key
279
+ // Note: jose library handles the signing, but we need to convert CryptoKey to JWK format
280
+ // For now, we'll use a workaround - in production, you'd use a proper JWT library
281
+ // that supports CryptoKey directly
282
+ // This is a simplified version - in production, use a library that supports CryptoKey
283
+ // For now, we'll need to export the private key and use it with jose
284
+ const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
285
+ // Ensure kty is defined and cast to JWK
286
+ if (!privateKeyJwk.kty || typeof privateKeyJwk.kty !== 'string') {
287
+ throw new Error('Private key JWK missing kty field');
288
+ }
289
+ // Import as JWK for jose - create a properly typed JWK object
290
+ const { importJWK } = await import('jose');
291
+ const jwkWithKty = {
292
+ ...privateKeyJwk,
293
+ kty: privateKeyJwk.kty,
294
+ };
295
+ const signingKey = await importJWK(jwkWithKty, DPOP_CONFIG.algorithm);
296
+ return await jwt.sign(signingKey);
297
+ }
298
+ /**
299
+ * Create DPoP proof JWT for resource server (PDS) requests
300
+ * Includes ath (access token hash) field
301
+ */
302
+ async function createDPoPProofForResource(keyPair, httpMethod, resourceUrl, accessToken, nonce) {
303
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
304
+ const publicKeyJwk = await exportPublicKeyAsJWK(keyPair.publicKey);
305
+ // Hash access token for ath field (same as S256 PKCE challenge)
306
+ const tokenHash = createHash('sha256').update(accessToken).digest();
307
+ const ath = base64UrlEncode(tokenHash);
308
+ const jti = base64UrlEncode(randomBytes(DPOP_CONFIG.jtiLength));
309
+ const now = Math.floor(Date.now() / 1000);
310
+ const jwt = new SignJWT({
311
+ jti,
312
+ htm: httpMethod,
313
+ htu: resourceUrl,
314
+ iat: now,
315
+ exp: now + 60,
316
+ ath,
317
+ ...(nonce && { nonce }),
318
+ })
319
+ .setProtectedHeader({
320
+ typ: 'dpop+jwt',
321
+ alg: DPOP_CONFIG.algorithm,
322
+ jwk: publicKeyJwk,
323
+ });
324
+ const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
325
+ // Ensure kty is defined and cast to JWK
326
+ if (!privateKeyJwk.kty || typeof privateKeyJwk.kty !== 'string') {
327
+ throw new Error('Private key JWK missing kty field');
328
+ }
329
+ // Import as JWK for jose - create a properly typed JWK object
330
+ const { importJWK } = await import('jose');
331
+ const jwkWithKty = {
332
+ ...privateKeyJwk,
333
+ kty: privateKeyJwk.kty,
334
+ };
335
+ const signingKey = await importJWK(jwkWithKty, DPOP_CONFIG.algorithm);
336
+ return await jwt.sign(signingKey);
337
+ }
338
+ /**
339
+ * Extract DPoP nonce from response headers
340
+ */
341
+ function extractDPoPNonce(headers) {
342
+ if (headers instanceof Headers) {
343
+ return headers.get('dpop-nonce') || null;
344
+ }
345
+ const dpopNonce = headers['dpop-nonce'] || headers['DPoP-Nonce'];
346
+ if (typeof dpopNonce === 'string') {
347
+ return dpopNonce;
348
+ }
349
+ if (Array.isArray(dpopNonce) && dpopNonce.length > 0 && typeof dpopNonce[0] === 'string') {
350
+ return dpopNonce[0];
351
+ }
352
+ return null;
353
+ }
354
+ /**
355
+ * Base64url encode
356
+ */
357
+ function base64UrlEncode(buffer) {
358
+ return buffer
359
+ .toString('base64')
360
+ .replace(/\+/g, '-')
361
+ .replace(/\//g, '_')
362
+ .replace(/=/g, '');
363
+ }
364
+
365
+ /**
366
+ * Validate URL to prevent SSRF attacks
367
+ * Rejects localhost, private IPs, and link-local addresses
368
+ */
369
+ function validateUrl(url) {
370
+ let parsed;
371
+ try {
372
+ parsed = new URL(url);
373
+ }
374
+ catch {
375
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
376
+ error: `Invalid URL: ${url}`,
377
+ });
378
+ }
379
+ // Must be HTTPS
380
+ if (parsed.protocol !== 'https:') {
381
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
382
+ error: 'URL must use HTTPS protocol',
383
+ });
384
+ }
385
+ // Check for localhost or private IPs
386
+ const hostname = parsed.hostname.toLowerCase();
387
+ if (hostname === 'localhost' ||
388
+ hostname === '127.0.0.1' ||
389
+ hostname === '::1' ||
390
+ hostname.startsWith('192.168.') ||
391
+ hostname.startsWith('10.') ||
392
+ hostname.startsWith('172.16.') ||
393
+ hostname.startsWith('172.17.') ||
394
+ hostname.startsWith('172.18.') ||
395
+ hostname.startsWith('172.19.') ||
396
+ hostname.startsWith('172.20.') ||
397
+ hostname.startsWith('172.21.') ||
398
+ hostname.startsWith('172.22.') ||
399
+ hostname.startsWith('172.23.') ||
400
+ hostname.startsWith('172.24.') ||
401
+ hostname.startsWith('172.25.') ||
402
+ hostname.startsWith('172.26.') ||
403
+ hostname.startsWith('172.27.') ||
404
+ hostname.startsWith('172.28.') ||
405
+ hostname.startsWith('172.29.') ||
406
+ hostname.startsWith('172.30.') ||
407
+ hostname.startsWith('172.31.') ||
408
+ hostname.startsWith('169.254.') || // Link-local
409
+ hostname.startsWith('fe80:') // IPv6 link-local
410
+ ) {
411
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
412
+ error: 'URL must not point to localhost or private IP addresses',
413
+ });
414
+ }
415
+ }
416
+ /**
417
+ * Hardened HTTP fetch with SSRF protection
418
+ */
419
+ async function hardenedFetch(url, options = {}) {
420
+ validateUrl(url);
421
+ try {
422
+ const method = options.method || 'GET';
423
+ const response = await got(url, {
424
+ method,
425
+ headers: options.headers,
426
+ body: options.body,
427
+ timeout: {
428
+ request: HTTP_CLIENT_CONFIG.timeout,
429
+ },
430
+ followRedirect: true,
431
+ maxRedirects: HTTP_CLIENT_CONFIG.maxRedirects,
432
+ });
433
+ // Convert got response to Response-like object
434
+ return {
435
+ ok: response.statusCode >= 200 && response.statusCode < 300,
436
+ status: response.statusCode,
437
+ statusText: response.statusMessage || '',
438
+ headers: new Headers(response.headers),
439
+ text: async () => response.body,
440
+ json: async () => JSON.parse(response.body),
441
+ };
442
+ }
443
+ catch (error) {
444
+ if (error instanceof Error) {
445
+ throw new ConnectorError(ConnectorErrorCodes.General, {
446
+ error: `HTTP request failed: ${error.message}`,
447
+ });
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+ /**
453
+ * Resolve handle to DID via .well-known/atproto-did
454
+ */
455
+ async function resolveHandleToDID(handle) {
456
+ // Remove @ prefix if present
457
+ const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
458
+ // Try .well-known/atproto-did first
459
+ const wellKnownUrl = `https://${cleanHandle}${WELL_KNOWN_PATHS.ATPROTO_DID}`;
460
+ try {
461
+ validateUrl(wellKnownUrl);
462
+ const response = await hardenedFetch(wellKnownUrl);
463
+ if (response.ok) {
464
+ const did = await response.text();
465
+ return did.trim();
466
+ }
467
+ }
468
+ catch (error) {
469
+ // Fall through to DNS TXT resolution (not implemented here - would need DNS library)
470
+ // For now, throw error
471
+ throw new ConnectorError(ConnectorErrorCodes.General, {
472
+ error: `Could not resolve handle to DID: ${error instanceof Error ? error.message : 'Unknown error'}`,
473
+ });
474
+ }
475
+ throw new ConnectorError(ConnectorErrorCodes.General, {
476
+ error: `Could not resolve handle: ${handle}`,
477
+ });
478
+ }
479
+ /**
480
+ * Resolve DID to PDS endpoint from DID document
481
+ */
482
+ async function resolvePDSFromDID(did) {
483
+ // For did:plc, use plc.directory
484
+ if (did.startsWith('did:plc:')) {
485
+ const didPath = did.replace('did:plc:', '');
486
+ const didDocUrl = `https://plc.directory/${didPath}`;
487
+ try {
488
+ validateUrl(didDocUrl);
489
+ const response = await hardenedFetch(didDocUrl);
490
+ if (response.ok) {
491
+ const didDoc = await response.json();
492
+ const parsed = didDocumentGuard.safeParse(didDoc);
493
+ if (parsed.success) {
494
+ const pdsService = parsed.data.service?.find((s) => s.type === 'AtprotoPersonalDataServer');
495
+ if (pdsService?.serviceEndpoint) {
496
+ validateUrl(pdsService.serviceEndpoint);
497
+ return pdsService.serviceEndpoint;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ catch (error) {
503
+ throw new ConnectorError(ConnectorErrorCodes.General, {
504
+ error: `Could not resolve DID document: ${error instanceof Error ? error.message : 'Unknown error'}`,
505
+ });
506
+ }
507
+ }
508
+ // For did:web, resolve from domain
509
+ if (did.startsWith('did:web:')) {
510
+ const domain = did.replace('did:web:', '').replace(/:/g, '/');
511
+ const didDocUrl = `https://${domain}/.well-known/did.json`;
512
+ try {
513
+ validateUrl(didDocUrl);
514
+ const response = await hardenedFetch(didDocUrl);
515
+ if (response.ok) {
516
+ const didDoc = await response.json();
517
+ const parsed = didDocumentGuard.safeParse(didDoc);
518
+ if (parsed.success) {
519
+ const pdsService = parsed.data.service?.find((s) => s.type === 'AtprotoPersonalDataServer');
520
+ if (pdsService?.serviceEndpoint) {
521
+ validateUrl(pdsService.serviceEndpoint);
522
+ return pdsService.serviceEndpoint;
523
+ }
524
+ }
525
+ }
526
+ }
527
+ catch (error) {
528
+ throw new ConnectorError(ConnectorErrorCodes.General, {
529
+ error: `Could not resolve DID document: ${error instanceof Error ? error.message : 'Unknown error'}`,
530
+ });
531
+ }
532
+ }
533
+ throw new ConnectorError(ConnectorErrorCodes.General, {
534
+ error: `Unsupported DID method or PDS not found for: ${did}`,
535
+ });
536
+ }
537
+ /**
538
+ * Resolve handle or DID to PDS endpoint
539
+ */
540
+ async function resolvePDS(identifier) {
541
+ // If it's already a DID, resolve directly
542
+ if (identifier.startsWith('did:')) {
543
+ return resolvePDSFromDID(identifier);
544
+ }
545
+ // Otherwise, treat as handle and resolve to DID first
546
+ const did = await resolveHandleToDID(identifier);
547
+ return resolvePDSFromDID(did);
548
+ }
549
+ /**
550
+ * Fetch authorization server metadata from PDS
551
+ */
552
+ async function fetchAuthorizationServerMetadata(pdsUrl) {
553
+ // First try protected resource metadata
554
+ const resourceMetadataUrl = `${pdsUrl}${WELL_KNOWN_PATHS.OAUTH_PROTECTED_RESOURCE}`;
555
+ try {
556
+ validateUrl(resourceMetadataUrl);
557
+ const resourceResponse = await hardenedFetch(resourceMetadataUrl);
558
+ if (resourceResponse.ok) {
559
+ const resourceMetadata = await resourceResponse.json();
560
+ const parsed = resourceServerMetadataGuard.safeParse(resourceMetadata);
561
+ if (parsed.success && parsed.data.authorization_servers.length > 0) {
562
+ // Use first authorization server
563
+ const authServerBase = parsed.data.authorization_servers[0];
564
+ if (authServerBase) {
565
+ // Construct full metadata URL
566
+ const authServerUrl = `${authServerBase}${WELL_KNOWN_PATHS.OAUTH_AUTHORIZATION_SERVER}`;
567
+ return fetchAuthorizationServerMetadataFromUrl(authServerUrl);
568
+ }
569
+ }
570
+ }
571
+ }
572
+ catch {
573
+ // Fall through to try PDS directly as auth server
574
+ }
575
+ // Try PDS directly as authorization server
576
+ const authServerUrl = `${pdsUrl}${WELL_KNOWN_PATHS.OAUTH_AUTHORIZATION_SERVER}`;
577
+ return fetchAuthorizationServerMetadataFromUrl(authServerUrl);
578
+ }
579
+ /**
580
+ * Fetch authorization server metadata from specific URL
581
+ */
582
+ async function fetchAuthorizationServerMetadataFromUrl(authServerUrl) {
583
+ validateUrl(authServerUrl);
584
+ const response = await hardenedFetch(authServerUrl);
585
+ if (!response.ok) {
586
+ throw new ConnectorError(ConnectorErrorCodes.General, {
587
+ error: `Failed to fetch authorization server metadata: ${response.status} ${response.statusText}`,
588
+ });
589
+ }
590
+ const metadata = await response.json();
591
+ const parsed = authorizationServerMetadataGuard.safeParse(metadata);
592
+ if (!parsed.success) {
593
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
594
+ error: 'Invalid authorization server metadata format',
595
+ });
596
+ }
597
+ // Validate that atproto scope is supported
598
+ const scopesSupported = parsed.data.scopes_supported;
599
+ if (typeof scopesSupported === 'string') {
600
+ const scopes = scopesSupported.split(' ');
601
+ if (!scopes.includes('atproto')) {
602
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
603
+ error: 'Authorization server does not support atproto scope',
604
+ });
605
+ }
606
+ }
607
+ return parsed.data;
608
+ }
609
+
610
+ /**
611
+ * Client Assertion Generation for private_key_jwt
612
+ *
613
+ * Generates JWT client assertion for OAuth token endpoint authentication
614
+ * RFC 7523: https://datatracker.ietf.org/doc/html/rfc7523
615
+ */
616
+ /**
617
+ * Generate client assertion JWT
618
+ *
619
+ * Note: This requires access to the private key, which should be:
620
+ * 1. Stored in Logto connector config (encrypted by Logto), OR
621
+ * 2. Retrieved from a secure server endpoint
622
+ *
623
+ * For now, we'll support both approaches - if privateKeyJwk is in config, use it.
624
+ * Otherwise, the server should provide an endpoint to generate assertions.
625
+ */
626
+ async function generateClientAssertion(clientId, tokenEndpoint, config) {
627
+ // Check if private key is available in config (for server-side connectors)
628
+ // In production, this would come from Vault via a server endpoint
629
+ const privateKeyJwkStr = config.privateKeyJwk;
630
+ if (!privateKeyJwkStr) {
631
+ // Private key not in config - would need to call server endpoint
632
+ // For now, return null to indicate assertion generation failed
633
+ return null;
634
+ }
635
+ try {
636
+ const privateKeyJwk = JSON.parse(privateKeyJwkStr);
637
+ const privateKey = await importJWK(privateKeyJwk, 'ES256');
638
+ const now = Math.floor(Date.now() / 1000);
639
+ const jti = randomBytes(16).toString('base64url');
640
+ const jwt = new SignJWT({
641
+ iss: clientId,
642
+ sub: clientId,
643
+ aud: tokenEndpoint,
644
+ jti,
645
+ exp: now + 600,
646
+ iat: now,
647
+ })
648
+ .setProtectedHeader({
649
+ alg: 'ES256',
650
+ typ: 'JWT',
651
+ });
652
+ return await jwt.sign(privateKey);
653
+ }
654
+ catch (error) {
655
+ throw new Error(`Failed to generate client assertion: ${error instanceof Error ? error.message : 'Unknown error'}`);
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Bluesky/AT Protocol OAuth 2.0 connector implementation
661
+ * Implements PAR (Pushed Authorization Requests), PKCE, and DPoP
662
+ * https://atproto.com/specs/oauth
663
+ */
664
+ const defaultTimeout = 10000;
665
+ /**
666
+ * Make PAR (Pushed Authorization Request) with DPoP and nonce retry
667
+ */
668
+ async function makePARRequest(parEndpoint, params, dpopKeyPair, nonce) {
669
+ let currentNonce = nonce;
670
+ let retries = 0;
671
+ const maxRetries = 3;
672
+ while (retries < maxRetries) {
673
+ try {
674
+ const dpopProof = await createDPoPProofForToken(dpopKeyPair, 'POST', parEndpoint, currentNonce);
675
+ const response = await got.post(parEndpoint, {
676
+ headers: {
677
+ 'Content-Type': 'application/x-www-form-urlencoded',
678
+ 'DPoP': dpopProof,
679
+ },
680
+ body: params.toString(),
681
+ timeout: { request: defaultTimeout },
682
+ });
683
+ // Check for nonce in response
684
+ const responseNonce = extractDPoPNonce(response.headers);
685
+ if (responseNonce && responseNonce !== currentNonce) {
686
+ currentNonce = responseNonce;
687
+ }
688
+ const result = parseJson(response.body);
689
+ if (result && result.request_uri) {
690
+ return {
691
+ requestUri: result.request_uri,
692
+ nonce: currentNonce || '',
693
+ };
694
+ }
695
+ // Check for use_dpop_nonce error
696
+ if (result && result.error === 'use_dpop_nonce') {
697
+ currentNonce = responseNonce || result.nonce || '';
698
+ retries++;
699
+ continue;
700
+ }
701
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
702
+ error: `PAR request failed: ${result?.error || 'Unknown error'}`,
703
+ });
704
+ }
705
+ catch (error) {
706
+ if (error instanceof HTTPError) {
707
+ const body = error.response?.body;
708
+ let errorData;
709
+ try {
710
+ errorData = typeof body === 'string' ? parseJson(body) : body;
711
+ }
712
+ catch {
713
+ errorData = body;
714
+ }
715
+ // Check for use_dpop_nonce error
716
+ if (errorData && typeof errorData === 'object' && 'error' in errorData && errorData.error === 'use_dpop_nonce') {
717
+ const headers = error.response?.headers || {};
718
+ currentNonce = extractDPoPNonce(headers) || errorData.nonce || '';
719
+ retries++;
720
+ continue;
721
+ }
722
+ throw new ConnectorError(ConnectorErrorCodes.General, {
723
+ error: `PAR request failed: ${error.response?.statusCode} ${error.response?.statusMessage}`,
724
+ });
725
+ }
726
+ throw error;
727
+ }
728
+ }
729
+ throw new ConnectorError(ConnectorErrorCodes.General, {
730
+ error: 'PAR request failed after retries',
731
+ });
732
+ }
733
+ /**
734
+ * Exchange authorization code for tokens with DPoP and nonce retry
735
+ */
736
+ async function exchangeCodeForTokens(code, redirectUri, tokenEndpoint, codeVerifier, dpopKeyPair, nonce, config) {
737
+ let currentNonce = nonce;
738
+ let retries = 0;
739
+ const maxRetries = 3;
740
+ while (retries < maxRetries) {
741
+ try {
742
+ const dpopProof = await createDPoPProofForToken(dpopKeyPair, 'POST', tokenEndpoint, currentNonce);
743
+ // Use clientId if provided, otherwise default to clientMetadataUri
744
+ const effectiveClientId = config.clientId || config.clientMetadataUri;
745
+ const params = new URLSearchParams({
746
+ grant_type: 'authorization_code',
747
+ code,
748
+ redirect_uri: redirectUri,
749
+ code_verifier: codeVerifier,
750
+ client_id: effectiveClientId,
751
+ });
752
+ const headers = {
753
+ 'Content-Type': 'application/x-www-form-urlencoded',
754
+ 'DPoP': dpopProof,
755
+ };
756
+ // For confidential clients, add client assertion JWT
757
+ if (config.tokenEndpointAuthMethod === 'private_key_jwt') {
758
+ try {
759
+ const clientAssertion = await generateClientAssertion(effectiveClientId, tokenEndpoint, config);
760
+ if (clientAssertion) {
761
+ params.append('client_assertion', clientAssertion);
762
+ params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
763
+ }
764
+ else {
765
+ // If assertion generation failed, try to get it from server endpoint
766
+ const assertionEndpoint = config.assertionEndpoint;
767
+ if (assertionEndpoint) {
768
+ const assertionResponse = await got.post(assertionEndpoint, {
769
+ json: { clientId: effectiveClientId, tokenEndpoint },
770
+ headers: {
771
+ 'X-API-Key': config.assertionApiKey || '',
772
+ },
773
+ timeout: { request: defaultTimeout },
774
+ });
775
+ const assertionData = parseJson(assertionResponse.body);
776
+ if (assertionData?.client_assertion) {
777
+ params.append('client_assertion', assertionData.client_assertion);
778
+ params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
779
+ }
780
+ else {
781
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
782
+ error: 'Client assertion required for private_key_jwt but could not be generated',
783
+ });
784
+ }
785
+ }
786
+ else {
787
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
788
+ error: 'Client assertion required for private_key_jwt but could not be generated. Provide privateKeyJwk in config or assertionEndpoint.',
789
+ });
790
+ }
791
+ }
792
+ }
793
+ catch (error) {
794
+ if (error instanceof ConnectorError) {
795
+ throw error;
796
+ }
797
+ throw new ConnectorError(ConnectorErrorCodes.General, {
798
+ error: `Failed to generate client assertion: ${error instanceof Error ? error.message : 'Unknown error'}`,
799
+ });
800
+ }
801
+ }
802
+ const response = await got.post(tokenEndpoint, {
803
+ headers,
804
+ body: params.toString(),
805
+ timeout: { request: defaultTimeout },
806
+ });
807
+ const responseNonce = extractDPoPNonce(response.headers);
808
+ if (responseNonce && responseNonce !== currentNonce) {
809
+ currentNonce = responseNonce;
810
+ }
811
+ const result = tokenResponseGuard.safeParse(parseJson(response.body));
812
+ if (result.success) {
813
+ return {
814
+ tokens: result.data,
815
+ nonce: currentNonce,
816
+ };
817
+ }
818
+ // Check for use_dpop_nonce error
819
+ const parsed = parseJson(response.body);
820
+ if (parsed && typeof parsed === 'object' && 'error' in parsed && parsed.error === 'use_dpop_nonce') {
821
+ currentNonce = responseNonce || parsed.nonce || '';
822
+ retries++;
823
+ continue;
824
+ }
825
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
826
+ }
827
+ catch (error) {
828
+ if (error instanceof HTTPError) {
829
+ const body = error.response?.body;
830
+ let errorData;
831
+ try {
832
+ errorData = typeof body === 'string' ? parseJson(body) : body;
833
+ }
834
+ catch {
835
+ errorData = body;
836
+ }
837
+ if (errorData && typeof errorData === 'object' && 'error' in errorData && errorData.error === 'use_dpop_nonce') {
838
+ const headers = error.response?.headers || {};
839
+ currentNonce = extractDPoPNonce(headers) || errorData.nonce || '';
840
+ retries++;
841
+ continue;
842
+ }
843
+ throw new ConnectorError(ConnectorErrorCodes.General, {
844
+ error: `Token exchange failed: ${error.response?.statusCode} ${error.response?.statusMessage}`,
845
+ });
846
+ }
847
+ throw error;
848
+ }
849
+ }
850
+ throw new ConnectorError(ConnectorErrorCodes.General, {
851
+ error: 'Token exchange failed after retries',
852
+ });
853
+ }
854
+ /**
855
+ * Fetch user profile from PDS with DPoP
856
+ */
857
+ async function fetchProfile(pdsUrl, accessToken, did, dpopKeyPair, nonce) {
858
+ let currentNonce = nonce;
859
+ let retries = 0;
860
+ const maxRetries = 3;
861
+ // AT Protocol profile endpoint
862
+ const profileUrl = `${pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`;
863
+ while (retries < maxRetries) {
864
+ try {
865
+ const dpopProof = await createDPoPProofForResource(dpopKeyPair, 'GET', profileUrl, accessToken, currentNonce);
866
+ const response = await got.get(profileUrl, {
867
+ headers: {
868
+ 'Authorization': `DPoP ${accessToken}`,
869
+ 'DPoP': dpopProof,
870
+ },
871
+ timeout: { request: defaultTimeout },
872
+ });
873
+ const responseNonce = extractDPoPNonce(response.headers);
874
+ if (responseNonce && responseNonce !== currentNonce) {
875
+ currentNonce = responseNonce;
876
+ }
877
+ const result = profileResponseGuard.safeParse(parseJson(response.body));
878
+ if (result.success) {
879
+ return result.data;
880
+ }
881
+ // Check for use_dpop_nonce error
882
+ const parsed = parseJson(response.body);
883
+ if (parsed && typeof parsed === 'object' && 'error' in parsed && parsed.error === 'use_dpop_nonce') {
884
+ currentNonce = responseNonce || parsed.nonce || '';
885
+ retries++;
886
+ continue;
887
+ }
888
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
889
+ }
890
+ catch (error) {
891
+ if (error instanceof HTTPError) {
892
+ const wwwAuth = error.response?.headers['www-authenticate'];
893
+ if (wwwAuth && wwwAuth.includes('use_dpop_nonce')) {
894
+ // Extract nonce from WWW-Authenticate header
895
+ const nonceMatch = wwwAuth.match(/nonce="([^"]+)"/);
896
+ if (nonceMatch) {
897
+ currentNonce = nonceMatch[1];
898
+ retries++;
899
+ continue;
900
+ }
901
+ }
902
+ throw new ConnectorError(ConnectorErrorCodes.General, {
903
+ error: `Profile fetch failed: ${error.response?.statusCode} ${error.response?.statusMessage}`,
904
+ });
905
+ }
906
+ throw error;
907
+ }
908
+ }
909
+ throw new ConnectorError(ConnectorErrorCodes.General, {
910
+ error: 'Profile fetch failed after retries',
911
+ });
912
+ }
913
+ /**
914
+ * Generate authorization URI using PAR
915
+ */
916
+ const getAuthorizationUri = (getConfig) => async ({ state, redirectUri, ...rest }) => {
917
+ const config = await getConfig(defaultMetadata.id);
918
+ validateConfig(config, blueskyConfigGuard);
919
+ const validatedConfig = config;
920
+ // Use clientId if provided, otherwise default to clientMetadataUri
921
+ const effectiveClientId = validatedConfig.clientId || validatedConfig.clientMetadataUri;
922
+ // Generate PKCE code pair
923
+ const pkce = generatePKCECodePair();
924
+ // Generate DPoP key pair
925
+ const dpopKeyPair = await generateDPoPKeyPair();
926
+ // Resolve PDS from login_hint if provided, otherwise use default
927
+ let authServerMetadata;
928
+ if ('login_hint' in rest && rest.login_hint) {
929
+ const pdsUrl = await resolvePDS(rest.login_hint);
930
+ authServerMetadata = await fetchAuthorizationServerMetadata(pdsUrl);
931
+ }
932
+ else {
933
+ // Use default bsky.social for now
934
+ // In production, you'd want to discover this differently
935
+ authServerMetadata = await fetchAuthorizationServerMetadata('https://bsky.social');
936
+ }
937
+ // Make PAR request
938
+ const parParams = new URLSearchParams({
939
+ client_id: effectiveClientId,
940
+ redirect_uri: redirectUri,
941
+ response_type: 'code',
942
+ scope: validatedConfig.scope,
943
+ state,
944
+ code_challenge: pkce.codeChallenge,
945
+ code_challenge_method: pkce.codeChallengeMethod,
946
+ ...('login_hint' in rest && rest.login_hint ? { login_hint: String(rest.login_hint) } : {}),
947
+ });
948
+ const { requestUri, nonce } = await makePARRequest(authServerMetadata.pushed_authorization_request_endpoint, parParams, dpopKeyPair);
949
+ // Store PKCE verifier and DPoP key pair in state store
950
+ // Export DPoP keypair as JWK for storage
951
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
952
+ const publicKeyJwk = await crypto.subtle.exportKey('jwk', dpopKeyPair.publicKey);
953
+ const privateKeyJwk = await crypto.subtle.exportKey('jwk', dpopKeyPair.privateKey);
954
+ // Try to store state via server endpoint if available
955
+ const stateEndpoint = config.stateEndpoint;
956
+ const stateApiKey = config.stateApiKey;
957
+ if (stateEndpoint && stateApiKey) {
958
+ try {
959
+ await got.post(stateEndpoint, {
960
+ json: {
961
+ state,
962
+ action: 'store',
963
+ data: {
964
+ pkce,
965
+ dpopKeyPair: {
966
+ publicKeyJwk: JSON.stringify(publicKeyJwk),
967
+ privateKeyJwk: JSON.stringify(privateKeyJwk),
968
+ },
969
+ requestUri,
970
+ authServerNonce: nonce,
971
+ },
972
+ },
973
+ headers: {
974
+ 'X-API-Key': stateApiKey,
975
+ },
976
+ timeout: { request: defaultTimeout },
977
+ });
978
+ }
979
+ catch (error) {
980
+ // Log but don't fail - state storage is best effort
981
+ console.warn('Failed to store OAuth state:', error);
982
+ }
983
+ }
984
+ // Build authorization URL with request_uri
985
+ const authParams = new URLSearchParams({
986
+ client_id: effectiveClientId,
987
+ request_uri: requestUri,
988
+ });
989
+ const queryString = authParams.toString();
990
+ const endpoint = authServerMetadata.authorization_endpoint;
991
+ // @ts-expect-error - TypeScript compiler limitation with deeply nested generic types
992
+ return `${endpoint}?${queryString}`;
993
+ };
994
+ /**
995
+ * Handle authorization callback and fetch user info
996
+ */
997
+ const getUserInfo = (getConfig) => async (data) => {
998
+ const { code, state, iss } = await authResponseGuard.parse(data);
999
+ const { redirectUri } = data;
1000
+ assert(code, new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'Missing authorization code'));
1001
+ assert(iss, new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'Missing authorization server issuer'));
1002
+ const config = await getConfig(defaultMetadata.id);
1003
+ validateConfig(config, blueskyConfigGuard);
1004
+ const validatedConfig = config;
1005
+ // Fetch authorization server metadata
1006
+ const authServerMetadata = await fetchAuthorizationServerMetadataFromIssuer(iss);
1007
+ // Retrieve PKCE verifier and DPoP key pair from state store
1008
+ assert(state, new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'Missing state parameter'));
1009
+ const stateEndpoint = validatedConfig.stateEndpoint;
1010
+ const stateApiKey = validatedConfig.stateApiKey;
1011
+ let pkce;
1012
+ let dpopKeyPair;
1013
+ let authServerNonce = '';
1014
+ if (stateEndpoint && stateApiKey) {
1015
+ try {
1016
+ const stateResponse = await got.post(stateEndpoint, {
1017
+ json: {
1018
+ state,
1019
+ action: 'get',
1020
+ },
1021
+ headers: {
1022
+ 'X-API-Key': stateApiKey,
1023
+ },
1024
+ timeout: { request: defaultTimeout },
1025
+ });
1026
+ const stateData = parseJson(stateResponse.body);
1027
+ if (stateData?.pkce && stateData.dpopKeyPair) {
1028
+ pkce = stateData.pkce;
1029
+ authServerNonce = stateData.authServerNonce || '';
1030
+ // Reconstruct DPoP keypair from JWK
1031
+ const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
1032
+ const { importJWK } = await import('jose');
1033
+ const publicKeyJwk = JSON.parse(stateData.dpopKeyPair.publicKeyJwk);
1034
+ const privateKeyJwk = JSON.parse(stateData.dpopKeyPair.privateKeyJwk);
1035
+ const publicKey = await importJWK(publicKeyJwk, 'ES256');
1036
+ const privateKey = await importJWK(privateKeyJwk, 'ES256');
1037
+ dpopKeyPair = {
1038
+ publicKey: publicKey,
1039
+ privateKey: privateKey,
1040
+ };
1041
+ // Delete state after use
1042
+ try {
1043
+ await got.post(stateEndpoint, {
1044
+ json: { state, action: 'delete' },
1045
+ headers: { 'X-API-Key': stateApiKey },
1046
+ timeout: { request: defaultTimeout },
1047
+ });
1048
+ }
1049
+ catch {
1050
+ // Ignore delete errors
1051
+ }
1052
+ }
1053
+ else {
1054
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
1055
+ error: 'Invalid state data retrieved',
1056
+ });
1057
+ }
1058
+ }
1059
+ catch (error) {
1060
+ if (error instanceof ConnectorError) {
1061
+ throw error;
1062
+ }
1063
+ // Fall back to generating new keys (not ideal, but allows flow to continue)
1064
+ console.warn('Failed to retrieve OAuth state, generating new keys:', error);
1065
+ pkce = generatePKCECodePair();
1066
+ dpopKeyPair = await generateDPoPKeyPair();
1067
+ }
1068
+ }
1069
+ else {
1070
+ // No state endpoint configured - generate new keys (not secure, but allows flow)
1071
+ console.warn('State endpoint not configured - generating new PKCE/DPoP keys (not recommended)');
1072
+ pkce = generatePKCECodePair();
1073
+ dpopKeyPair = await generateDPoPKeyPair();
1074
+ }
1075
+ // Exchange code for tokens
1076
+ const { tokens, nonce } = await exchangeCodeForTokens(code, redirectUri, authServerMetadata.token_endpoint, pkce.codeVerifier, dpopKeyPair, authServerNonce, // Use nonce from state if available
1077
+ validatedConfig);
1078
+ // Verify DID matches expected (if login_hint was provided)
1079
+ const did = tokens.did || tokens.sub;
1080
+ assert(did, new ConnectorError(ConnectorErrorCodes.InvalidResponse, 'Missing DID in token response'));
1081
+ // Resolve PDS from DID
1082
+ const pdsUrl = await resolvePDS(did);
1083
+ // Fetch profile from PDS
1084
+ const profile = await fetchProfile(pdsUrl, tokens.access_token, did, dpopKeyPair, nonce);
1085
+ return {
1086
+ id: did,
1087
+ name: profile.displayName || profile.handle,
1088
+ email: profile.email,
1089
+ avatar: profile.avatar,
1090
+ rawData: jsonGuard.parse({
1091
+ did,
1092
+ handle: profile.handle,
1093
+ pds: pdsUrl,
1094
+ displayName: profile.displayName,
1095
+ description: profile.description,
1096
+ }),
1097
+ };
1098
+ };
1099
+ /**
1100
+ * Fetch authorization server metadata from issuer URL
1101
+ */
1102
+ async function fetchAuthorizationServerMetadataFromIssuer(issuer) {
1103
+ const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`;
1104
+ const response = await got.get(metadataUrl, {
1105
+ timeout: { request: defaultTimeout },
1106
+ });
1107
+ const result = authorizationServerMetadataGuard.safeParse(parseJson(response.body));
1108
+ if (!result.success) {
1109
+ throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
1110
+ error: 'Invalid authorization server metadata',
1111
+ });
1112
+ }
1113
+ return result.data;
1114
+ }
1115
+ /**
1116
+ * Create the Bluesky connector instance
1117
+ */
1118
+ const createBlueskyConnector = async ({ getConfig, }) => {
1119
+ return {
1120
+ metadata: defaultMetadata,
1121
+ type: ConnectorType.Social,
1122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1123
+ configGuard: blueskyConfigGuard,
1124
+ getAuthorizationUri: getAuthorizationUri(getConfig),
1125
+ getUserInfo: getUserInfo(getConfig),
1126
+ };
1127
+ };
1128
+
1129
+ export { createBlueskyConnector as default };