@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.
- package/lib/__fixtures__/did-documents.d.ts +54 -0
- package/lib/__fixtures__/did-documents.d.ts.map +1 -0
- package/lib/__fixtures__/did-documents.js +56 -0
- package/lib/__fixtures__/metadata.d.ts +70 -0
- package/lib/__fixtures__/metadata.d.ts.map +1 -0
- package/lib/__fixtures__/metadata.js +56 -0
- package/lib/__fixtures__/oauth-errors.d.ts +42 -0
- package/lib/__fixtures__/oauth-errors.d.ts.map +1 -0
- package/lib/__fixtures__/oauth-errors.js +41 -0
- package/lib/__fixtures__/profile-responses.d.ts +34 -0
- package/lib/__fixtures__/profile-responses.d.ts.map +1 -0
- package/lib/__fixtures__/profile-responses.js +28 -0
- package/lib/__fixtures__/token-responses.d.ts +38 -0
- package/lib/__fixtures__/token-responses.d.ts.map +1 -0
- package/lib/__fixtures__/token-responses.js +33 -0
- package/lib/__tests__/integration/real-account-helpers.d.ts +43 -0
- package/lib/__tests__/integration/real-account-helpers.d.ts.map +1 -0
- package/lib/__tests__/integration/real-account-helpers.js +84 -0
- package/lib/__tests__/integration/real-account.test.d.ts +12 -0
- package/lib/__tests__/integration/real-account.test.d.ts.map +1 -0
- package/lib/__tests__/integration/real-account.test.js +118 -0
- package/lib/client-assertion.d.ts +19 -0
- package/lib/client-assertion.d.ts.map +1 -0
- package/lib/client-assertion.js +50 -0
- package/lib/client-assertion.test.d.ts +6 -0
- package/lib/client-assertion.test.d.ts.map +1 -0
- package/lib/client-assertion.test.js +234 -0
- package/lib/constant.d.ts +24 -0
- package/lib/constant.d.ts.map +1 -0
- package/lib/constant.js +77 -0
- package/lib/dpop.d.ts +24 -0
- package/lib/dpop.d.ts.map +1 -0
- package/lib/dpop.js +138 -0
- package/lib/dpop.test.d.ts +6 -0
- package/lib/dpop.test.d.ts.map +1 -0
- package/lib/dpop.test.js +266 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1129 -0
- package/lib/index.test.d.ts +7 -0
- package/lib/index.test.d.ts.map +1 -0
- package/lib/index.test.js +329 -0
- package/lib/mock.d.ts +5 -0
- package/lib/mock.d.ts.map +1 -0
- package/lib/mock.js +24 -0
- package/lib/pds-discovery.d.ts +18 -0
- package/lib/pds-discovery.d.ts.map +1 -0
- package/lib/pds-discovery.js +248 -0
- package/lib/pds-discovery.test.d.ts +6 -0
- package/lib/pds-discovery.test.d.ts.map +1 -0
- package/lib/pds-discovery.test.js +281 -0
- package/lib/pkce.d.ts +16 -0
- package/lib/pkce.d.ts.map +1 -0
- package/lib/pkce.js +46 -0
- package/lib/pkce.test.d.ts +6 -0
- package/lib/pkce.test.d.ts.map +1 -0
- package/lib/pkce.test.js +117 -0
- package/lib/test-utils.d.ts +65 -0
- package/lib/test-utils.d.ts.map +1 -0
- package/lib/test-utils.js +132 -0
- package/lib/types.d.ts +221 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +95 -0
- 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 };
|