@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bluesky Connector Tests
|
|
3
|
+
*
|
|
4
|
+
* Basic unit tests for Bluesky OAuth connector with PAR, PKCE, and DPoP
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
7
|
+
import nock from 'nock';
|
|
8
|
+
import { ConnectorError } from '@logto/connector-kit';
|
|
9
|
+
import { generatePKCECodePair } from './pkce.js';
|
|
10
|
+
import { generateDPoPKeyPair } from './dpop.js';
|
|
11
|
+
import { blueskyConfigGuard } from './types.js';
|
|
12
|
+
import createBlueskyConnector from './index.js';
|
|
13
|
+
import { authorizationServerMetadata, resourceServerMetadata, } from './__fixtures__/metadata.js';
|
|
14
|
+
import { tokenResponse, } from './__fixtures__/token-responses.js';
|
|
15
|
+
import { profileResponse, } from './__fixtures__/profile-responses.js';
|
|
16
|
+
import { useDpopNonceError, invalidGrantError, } from './__fixtures__/oauth-errors.js';
|
|
17
|
+
import { didPlcDocument, } from './__fixtures__/did-documents.js';
|
|
18
|
+
describe('Bluesky Connector', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
nock.cleanAll();
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
nock.cleanAll();
|
|
24
|
+
});
|
|
25
|
+
describe('PKCE Generation', () => {
|
|
26
|
+
it('should generate valid PKCE code pair', () => {
|
|
27
|
+
const pkce = generatePKCECodePair();
|
|
28
|
+
expect(pkce.codeVerifier).toBeDefined();
|
|
29
|
+
expect(pkce.codeChallenge).toBeDefined();
|
|
30
|
+
expect(pkce.codeChallengeMethod).toBe('S256');
|
|
31
|
+
expect(pkce.codeVerifier.length).toBeGreaterThan(32);
|
|
32
|
+
expect(pkce.codeChallenge.length).toBeGreaterThan(32);
|
|
33
|
+
});
|
|
34
|
+
it('should generate different code pairs each time', () => {
|
|
35
|
+
const pkce1 = generatePKCECodePair();
|
|
36
|
+
const pkce2 = generatePKCECodePair();
|
|
37
|
+
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
|
|
38
|
+
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('DPoP Key Generation', () => {
|
|
42
|
+
it('should generate valid DPoP key pair', async () => {
|
|
43
|
+
const keyPair = await generateDPoPKeyPair();
|
|
44
|
+
expect(keyPair.publicKey).toBeDefined();
|
|
45
|
+
expect(keyPair.privateKey).toBeDefined();
|
|
46
|
+
expect(keyPair.publicKey).toBeInstanceOf(CryptoKey);
|
|
47
|
+
expect(keyPair.privateKey).toBeInstanceOf(CryptoKey);
|
|
48
|
+
});
|
|
49
|
+
it('should generate different key pairs each time', async () => {
|
|
50
|
+
const keyPair1 = await generateDPoPKeyPair();
|
|
51
|
+
const keyPair2 = await generateDPoPKeyPair();
|
|
52
|
+
// Export and compare
|
|
53
|
+
const crypto = globalThis.crypto;
|
|
54
|
+
const jwk1 = await crypto.subtle.exportKey('jwk', keyPair1.privateKey);
|
|
55
|
+
const jwk2 = await crypto.subtle.exportKey('jwk', keyPair2.privateKey);
|
|
56
|
+
expect(jwk1.d).not.toBe(jwk2.d);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('Config Validation', () => {
|
|
60
|
+
it('should accept valid config with private_key_jwt', () => {
|
|
61
|
+
const config = {
|
|
62
|
+
clientMetadataUri: 'https://example.com/.well-known/oauth-client-metadata.json',
|
|
63
|
+
clientId: 'https://example.com/client-id',
|
|
64
|
+
jwksUri: 'https://example.com/.well-known/jwks.json',
|
|
65
|
+
scope: 'atproto transition:generic',
|
|
66
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
67
|
+
};
|
|
68
|
+
const result = blueskyConfigGuard.safeParse(config);
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('should reject config without jwksUri for private_key_jwt', () => {
|
|
72
|
+
const config = {
|
|
73
|
+
clientMetadataUri: 'https://example.com/.well-known/oauth-client-metadata.json',
|
|
74
|
+
clientId: 'https://example.com/client-id',
|
|
75
|
+
scope: 'atproto transition:generic',
|
|
76
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
77
|
+
};
|
|
78
|
+
const result = blueskyConfigGuard.safeParse(config);
|
|
79
|
+
expect(result.success).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('should reject config with invalid URLs', () => {
|
|
82
|
+
const config = {
|
|
83
|
+
clientMetadataUri: 'not-a-url',
|
|
84
|
+
clientId: 'https://example.com/client-id',
|
|
85
|
+
jwksUri: 'https://example.com/.well-known/jwks.json',
|
|
86
|
+
scope: 'atproto transition:generic',
|
|
87
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
88
|
+
};
|
|
89
|
+
const result = blueskyConfigGuard.safeParse(config);
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('OAuth Flow Integration', () => {
|
|
94
|
+
const mockConfig = jest.fn(async () => ({
|
|
95
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
96
|
+
clientId: 'https://app.example.com/client-id',
|
|
97
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
98
|
+
scope: 'atproto transition:generic',
|
|
99
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
100
|
+
}));
|
|
101
|
+
const setSession = jest.fn();
|
|
102
|
+
const getSession = jest.fn();
|
|
103
|
+
it('should complete full OAuth flow with handle', async () => {
|
|
104
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
105
|
+
const handle = 'example.bsky.social';
|
|
106
|
+
const did = 'did:plc:example123456789';
|
|
107
|
+
const state = 'test-state-12345';
|
|
108
|
+
const redirectUri = 'https://app.example.com/oauth/callback';
|
|
109
|
+
const code = 'auth-code-12345';
|
|
110
|
+
const nonce = 'dpop-nonce-12345';
|
|
111
|
+
// Mock handle resolution
|
|
112
|
+
nock('https://example.bsky.social')
|
|
113
|
+
.get('/.well-known/atproto-did')
|
|
114
|
+
.reply(200, did);
|
|
115
|
+
// Mock DID document resolution
|
|
116
|
+
nock('https://plc.directory')
|
|
117
|
+
.get('/example123456789')
|
|
118
|
+
.reply(200, didPlcDocument);
|
|
119
|
+
// Mock protected resource metadata
|
|
120
|
+
nock('https://bsky.social')
|
|
121
|
+
.get('/.well-known/oauth-protected-resource')
|
|
122
|
+
.reply(200, resourceServerMetadata);
|
|
123
|
+
// Mock authorization server metadata
|
|
124
|
+
// fetchAuthorizationServerMetadata will try protected resource first, then auth server
|
|
125
|
+
// got library checks root URL first, so we need to mock both
|
|
126
|
+
nock('https://bsky.social')
|
|
127
|
+
.get('/')
|
|
128
|
+
.reply(200, '<html></html>', { 'content-type': 'text/html' })
|
|
129
|
+
.persist();
|
|
130
|
+
nock('https://bsky.social')
|
|
131
|
+
.get('/.well-known/oauth-authorization-server')
|
|
132
|
+
.reply(200, authorizationServerMetadata)
|
|
133
|
+
.persist();
|
|
134
|
+
// Mock PAR request (with nonce retry)
|
|
135
|
+
nock('https://bsky.social')
|
|
136
|
+
.post('/oauth/par')
|
|
137
|
+
.reply(401, useDpopNonceError, {
|
|
138
|
+
'dpop-nonce': nonce,
|
|
139
|
+
});
|
|
140
|
+
nock('https://bsky.social')
|
|
141
|
+
.post('/oauth/par')
|
|
142
|
+
.reply(200, {
|
|
143
|
+
request_uri: 'urn:ietf:params:oauth:request_uri:test-request-uri',
|
|
144
|
+
expires_in: 600,
|
|
145
|
+
}, {
|
|
146
|
+
'dpop-nonce': nonce,
|
|
147
|
+
});
|
|
148
|
+
// Generate authorization URI
|
|
149
|
+
const authUri = await connector.getAuthorizationUri({
|
|
150
|
+
state,
|
|
151
|
+
redirectUri,
|
|
152
|
+
connectorId: 'bluesky-web',
|
|
153
|
+
connectorFactoryId: 'bluesky',
|
|
154
|
+
jti: 'test-jti',
|
|
155
|
+
headers: {},
|
|
156
|
+
}, setSession);
|
|
157
|
+
expect(authUri).toContain('request_uri');
|
|
158
|
+
expect(authUri).toContain('client_id');
|
|
159
|
+
// Mock token exchange
|
|
160
|
+
nock('https://bsky.social')
|
|
161
|
+
.post('/oauth/token')
|
|
162
|
+
.reply(200, tokenResponse, {
|
|
163
|
+
'dpop-nonce': nonce,
|
|
164
|
+
});
|
|
165
|
+
// Mock profile fetch
|
|
166
|
+
nock('https://bsky.social')
|
|
167
|
+
.get(/\/xrpc\/app\.bsky\.actor\.getProfile/)
|
|
168
|
+
.reply(200, profileResponse, {
|
|
169
|
+
'dpop-nonce': nonce,
|
|
170
|
+
});
|
|
171
|
+
// Mock authorization server metadata from issuer
|
|
172
|
+
nock('https://bsky.social')
|
|
173
|
+
.get('/.well-known/oauth-authorization-server')
|
|
174
|
+
.reply(200, authorizationServerMetadata);
|
|
175
|
+
// Note: In a real test, we'd need to mock state storage/retrieval
|
|
176
|
+
// For now, this tests the flow structure
|
|
177
|
+
});
|
|
178
|
+
it('should handle PAR request with DPoP nonce retry', async () => {
|
|
179
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
180
|
+
const state = 'test-state-12345';
|
|
181
|
+
const redirectUri = 'https://app.example.com/oauth/callback';
|
|
182
|
+
const nonce = 'dpop-nonce-12345';
|
|
183
|
+
// Mock metadata
|
|
184
|
+
nock('https://bsky.social')
|
|
185
|
+
.get('/.well-known/oauth-protected-resource')
|
|
186
|
+
.reply(404);
|
|
187
|
+
nock('https://bsky.social')
|
|
188
|
+
.get('/.well-known/oauth-authorization-server')
|
|
189
|
+
.reply(200, authorizationServerMetadata);
|
|
190
|
+
// First PAR request fails with nonce error
|
|
191
|
+
nock('https://bsky.social')
|
|
192
|
+
.post('/oauth/par')
|
|
193
|
+
.reply(401, useDpopNonceError, {
|
|
194
|
+
'dpop-nonce': nonce,
|
|
195
|
+
});
|
|
196
|
+
// Second PAR request succeeds with nonce
|
|
197
|
+
nock('https://bsky.social')
|
|
198
|
+
.post('/oauth/par')
|
|
199
|
+
.reply(200, {
|
|
200
|
+
request_uri: 'urn:ietf:params:oauth:request_uri:test-request-uri',
|
|
201
|
+
}, {
|
|
202
|
+
'dpop-nonce': nonce,
|
|
203
|
+
});
|
|
204
|
+
const authUri = await connector.getAuthorizationUri({
|
|
205
|
+
state,
|
|
206
|
+
redirectUri,
|
|
207
|
+
connectorId: 'bluesky-web',
|
|
208
|
+
connectorFactoryId: 'bluesky',
|
|
209
|
+
jti: 'test-jti',
|
|
210
|
+
headers: {},
|
|
211
|
+
}, setSession);
|
|
212
|
+
expect(authUri).toBeDefined();
|
|
213
|
+
});
|
|
214
|
+
it('should handle token exchange errors', async () => {
|
|
215
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
216
|
+
// Mock metadata
|
|
217
|
+
nock('https://bsky.social')
|
|
218
|
+
.get('/.well-known/oauth-authorization-server')
|
|
219
|
+
.reply(200, authorizationServerMetadata);
|
|
220
|
+
// Mock invalid grant error
|
|
221
|
+
nock('https://bsky.social')
|
|
222
|
+
.post('/oauth/token')
|
|
223
|
+
.reply(400, invalidGrantError);
|
|
224
|
+
// This would be called in getUserInfo, but we need state storage mocked
|
|
225
|
+
// For now, we test that the error structure is correct
|
|
226
|
+
expect(invalidGrantError.error).toBe('invalid_grant');
|
|
227
|
+
});
|
|
228
|
+
it('should validate state parameter prevents CSRF', async () => {
|
|
229
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
230
|
+
const state = 'csrf-protection-state';
|
|
231
|
+
const redirectUri = 'https://app.example.com/oauth/callback';
|
|
232
|
+
// Mock metadata
|
|
233
|
+
nock('https://bsky.social')
|
|
234
|
+
.get('/.well-known/oauth-protected-resource')
|
|
235
|
+
.reply(404);
|
|
236
|
+
nock('https://bsky.social')
|
|
237
|
+
.get('/.well-known/oauth-authorization-server')
|
|
238
|
+
.reply(200, authorizationServerMetadata);
|
|
239
|
+
nock('https://bsky.social')
|
|
240
|
+
.post('/oauth/par')
|
|
241
|
+
.reply(200, {
|
|
242
|
+
request_uri: 'urn:ietf:params:oauth:request_uri:test',
|
|
243
|
+
});
|
|
244
|
+
const authUri = await connector.getAuthorizationUri({
|
|
245
|
+
state,
|
|
246
|
+
redirectUri,
|
|
247
|
+
connectorId: 'bluesky-web',
|
|
248
|
+
connectorFactoryId: 'bluesky',
|
|
249
|
+
jti: 'test-jti',
|
|
250
|
+
headers: {},
|
|
251
|
+
}, setSession);
|
|
252
|
+
// State should be included in PAR request
|
|
253
|
+
expect(authUri).toBeDefined();
|
|
254
|
+
// In getUserInfo, state mismatch should be detected
|
|
255
|
+
// This requires state storage to be properly implemented
|
|
256
|
+
});
|
|
257
|
+
it('should handle missing authorization code', async () => {
|
|
258
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
259
|
+
await expect(connector.getUserInfo({
|
|
260
|
+
redirectUri: 'https://app.example.com/oauth/callback',
|
|
261
|
+
// Missing code
|
|
262
|
+
}, getSession)).rejects.toThrow(ConnectorError);
|
|
263
|
+
});
|
|
264
|
+
it('should handle missing state parameter', async () => {
|
|
265
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
266
|
+
// Mock metadata
|
|
267
|
+
nock('https://bsky.social')
|
|
268
|
+
.get('/.well-known/oauth-authorization-server')
|
|
269
|
+
.reply(200, authorizationServerMetadata);
|
|
270
|
+
await expect(connector.getUserInfo({
|
|
271
|
+
code: 'auth-code-12345',
|
|
272
|
+
iss: 'https://bsky.social',
|
|
273
|
+
redirectUri: 'https://app.example.com/oauth/callback',
|
|
274
|
+
// Missing state
|
|
275
|
+
}, getSession)).rejects.toThrow(ConnectorError);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe('Security Tests', () => {
|
|
279
|
+
const mockConfig = jest.fn(async () => ({
|
|
280
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
281
|
+
clientId: 'https://app.example.com/client-id',
|
|
282
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
283
|
+
scope: 'atproto transition:generic',
|
|
284
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
285
|
+
}));
|
|
286
|
+
const setSession = jest.fn();
|
|
287
|
+
const getSession = jest.fn();
|
|
288
|
+
it('should not expose private keys in error messages', async () => {
|
|
289
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
290
|
+
// Mock metadata
|
|
291
|
+
nock('https://bsky.social')
|
|
292
|
+
.get('/.well-known/oauth-protected-resource')
|
|
293
|
+
.reply(404);
|
|
294
|
+
nock('https://bsky.social')
|
|
295
|
+
.get('/.well-known/oauth-authorization-server')
|
|
296
|
+
.reply(200, authorizationServerMetadata);
|
|
297
|
+
nock('https://bsky.social')
|
|
298
|
+
.post('/oauth/par')
|
|
299
|
+
.reply(500, { error: 'server_error' });
|
|
300
|
+
try {
|
|
301
|
+
await connector.getAuthorizationUri({
|
|
302
|
+
state: 'test-state',
|
|
303
|
+
redirectUri: 'https://app.example.com/oauth/callback',
|
|
304
|
+
connectorId: 'bluesky-web',
|
|
305
|
+
connectorFactoryId: 'bluesky',
|
|
306
|
+
jti: 'test-jti',
|
|
307
|
+
headers: {},
|
|
308
|
+
}, setSession);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
312
|
+
// Should not contain private key material
|
|
313
|
+
expect(errorMessage).not.toMatch(/-----BEGIN.*KEY-----/);
|
|
314
|
+
expect(errorMessage).not.toMatch(/d=/); // Private key field in JWK
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
it('should enforce state parameter validation', async () => {
|
|
318
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
319
|
+
// State parameter is required and should be validated
|
|
320
|
+
// This is tested in the missing state parameter test above
|
|
321
|
+
await expect(connector.getUserInfo({
|
|
322
|
+
code: 'auth-code-12345',
|
|
323
|
+
iss: 'https://bsky.social',
|
|
324
|
+
redirectUri: 'https://app.example.com/oauth/callback',
|
|
325
|
+
// Missing state
|
|
326
|
+
}, getSession)).rejects.toThrow(ConnectorError);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
package/lib/mock.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BlueskyConfig, TokenResponse, ProfileResponse } from './types.js';
|
|
2
|
+
export declare const mockConfig: BlueskyConfig;
|
|
3
|
+
export declare const mockTokenResponse: TokenResponse;
|
|
4
|
+
export declare const mockProfileResponse: ProfileResponse;
|
|
5
|
+
//# sourceMappingURL=mock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../src/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEhF,eAAO,MAAM,UAAU,EAAE,aAMxB,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,aAQ/B,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,eAOjC,CAAC"}
|
package/lib/mock.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const mockConfig = {
|
|
2
|
+
clientMetadataUri: 'https://app.example.com/.well-known/oauth-client-metadata.json',
|
|
3
|
+
clientId: 'https://app.example.com/client-id',
|
|
4
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
5
|
+
scope: 'atproto transition:generic',
|
|
6
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
7
|
+
};
|
|
8
|
+
export const mockTokenResponse = {
|
|
9
|
+
access_token: 'mock_access_token_12345',
|
|
10
|
+
refresh_token: 'mock_refresh_token_67890',
|
|
11
|
+
token_type: 'Bearer',
|
|
12
|
+
expires_in: 3600,
|
|
13
|
+
scope: 'atproto transition:generic',
|
|
14
|
+
sub: 'did:plc:mockdid123456789',
|
|
15
|
+
did: 'did:plc:mockdid123456789',
|
|
16
|
+
};
|
|
17
|
+
export const mockProfileResponse = {
|
|
18
|
+
did: 'did:plc:mockdid123456789',
|
|
19
|
+
handle: 'example.bsky.social',
|
|
20
|
+
displayName: 'Example User',
|
|
21
|
+
avatar: 'https://example.com/avatar.jpg',
|
|
22
|
+
email: 'user@example.com',
|
|
23
|
+
description: 'Mock user profile',
|
|
24
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuthorizationServerMetadata } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve handle to DID via .well-known/atproto-did
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveHandleToDID(handle: string): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve DID to PDS endpoint from DID document
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolvePDSFromDID(did: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve handle or DID to PDS endpoint
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolvePDS(identifier: string): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Fetch authorization server metadata from PDS
|
|
16
|
+
*/
|
|
17
|
+
export declare function fetchAuthorizationServerMetadata(pdsUrl: string): Promise<AuthorizationServerMetadata>;
|
|
18
|
+
//# sourceMappingURL=pds-discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pds-discovery.d.ts","sourceRoot":"","sources":["../src/pds-discovery.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAe,2BAA2B,EAA0B,MAAM,YAAY,CAAC;AAiGnG;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0BxE;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkEpE;AAED;;GAEG;AACH,wBAAsB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CASpE;AAED;;GAEG;AACH,wBAAsB,gCAAgC,CACpD,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,2BAA2B,CAAC,CA6BtC"}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
import { didDocumentGuard, authorizationServerMetadataGuard, resourceServerMetadataGuard } from './types.js';
|
|
3
|
+
import { WELL_KNOWN_PATHS, HTTP_CLIENT_CONFIG } from './constant.js';
|
|
4
|
+
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
|
5
|
+
/**
|
|
6
|
+
* Validate URL to prevent SSRF attacks
|
|
7
|
+
* Rejects localhost, private IPs, and link-local addresses
|
|
8
|
+
*/
|
|
9
|
+
function validateUrl(url) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(url);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
|
|
16
|
+
error: `Invalid URL: ${url}`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// Must be HTTPS
|
|
20
|
+
if (parsed.protocol !== 'https:') {
|
|
21
|
+
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
|
|
22
|
+
error: 'URL must use HTTPS protocol',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Check for localhost or private IPs
|
|
26
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
27
|
+
if (hostname === 'localhost' ||
|
|
28
|
+
hostname === '127.0.0.1' ||
|
|
29
|
+
hostname === '::1' ||
|
|
30
|
+
hostname.startsWith('192.168.') ||
|
|
31
|
+
hostname.startsWith('10.') ||
|
|
32
|
+
hostname.startsWith('172.16.') ||
|
|
33
|
+
hostname.startsWith('172.17.') ||
|
|
34
|
+
hostname.startsWith('172.18.') ||
|
|
35
|
+
hostname.startsWith('172.19.') ||
|
|
36
|
+
hostname.startsWith('172.20.') ||
|
|
37
|
+
hostname.startsWith('172.21.') ||
|
|
38
|
+
hostname.startsWith('172.22.') ||
|
|
39
|
+
hostname.startsWith('172.23.') ||
|
|
40
|
+
hostname.startsWith('172.24.') ||
|
|
41
|
+
hostname.startsWith('172.25.') ||
|
|
42
|
+
hostname.startsWith('172.26.') ||
|
|
43
|
+
hostname.startsWith('172.27.') ||
|
|
44
|
+
hostname.startsWith('172.28.') ||
|
|
45
|
+
hostname.startsWith('172.29.') ||
|
|
46
|
+
hostname.startsWith('172.30.') ||
|
|
47
|
+
hostname.startsWith('172.31.') ||
|
|
48
|
+
hostname.startsWith('169.254.') || // Link-local
|
|
49
|
+
hostname.startsWith('fe80:') // IPv6 link-local
|
|
50
|
+
) {
|
|
51
|
+
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
|
|
52
|
+
error: 'URL must not point to localhost or private IP addresses',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Hardened HTTP fetch with SSRF protection
|
|
58
|
+
*/
|
|
59
|
+
async function hardenedFetch(url, options = {}) {
|
|
60
|
+
validateUrl(url);
|
|
61
|
+
try {
|
|
62
|
+
const method = options.method || 'GET';
|
|
63
|
+
const response = await got(url, {
|
|
64
|
+
method,
|
|
65
|
+
headers: options.headers,
|
|
66
|
+
body: options.body,
|
|
67
|
+
timeout: {
|
|
68
|
+
request: HTTP_CLIENT_CONFIG.timeout,
|
|
69
|
+
},
|
|
70
|
+
followRedirect: true,
|
|
71
|
+
maxRedirects: HTTP_CLIENT_CONFIG.maxRedirects,
|
|
72
|
+
});
|
|
73
|
+
// Convert got response to Response-like object
|
|
74
|
+
return {
|
|
75
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
76
|
+
status: response.statusCode,
|
|
77
|
+
statusText: response.statusMessage || '',
|
|
78
|
+
headers: new Headers(response.headers),
|
|
79
|
+
text: async () => response.body,
|
|
80
|
+
json: async () => JSON.parse(response.body),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
86
|
+
error: `HTTP request failed: ${error.message}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve handle to DID via .well-known/atproto-did
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveHandleToDID(handle) {
|
|
96
|
+
// Remove @ prefix if present
|
|
97
|
+
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
|
|
98
|
+
// Try .well-known/atproto-did first
|
|
99
|
+
const wellKnownUrl = `https://${cleanHandle}${WELL_KNOWN_PATHS.ATPROTO_DID}`;
|
|
100
|
+
try {
|
|
101
|
+
validateUrl(wellKnownUrl);
|
|
102
|
+
const response = await hardenedFetch(wellKnownUrl);
|
|
103
|
+
if (response.ok) {
|
|
104
|
+
const did = await response.text();
|
|
105
|
+
return did.trim();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
// Fall through to DNS TXT resolution (not implemented here - would need DNS library)
|
|
110
|
+
// For now, throw error
|
|
111
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
112
|
+
error: `Could not resolve handle to DID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
116
|
+
error: `Could not resolve handle: ${handle}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve DID to PDS endpoint from DID document
|
|
121
|
+
*/
|
|
122
|
+
export async function resolvePDSFromDID(did) {
|
|
123
|
+
// For did:plc, use plc.directory
|
|
124
|
+
if (did.startsWith('did:plc:')) {
|
|
125
|
+
const didPath = did.replace('did:plc:', '');
|
|
126
|
+
const didDocUrl = `https://plc.directory/${didPath}`;
|
|
127
|
+
try {
|
|
128
|
+
validateUrl(didDocUrl);
|
|
129
|
+
const response = await hardenedFetch(didDocUrl);
|
|
130
|
+
if (response.ok) {
|
|
131
|
+
const didDoc = await response.json();
|
|
132
|
+
const parsed = didDocumentGuard.safeParse(didDoc);
|
|
133
|
+
if (parsed.success) {
|
|
134
|
+
const pdsService = parsed.data.service?.find((s) => s.type === 'AtprotoPersonalDataServer');
|
|
135
|
+
if (pdsService?.serviceEndpoint) {
|
|
136
|
+
validateUrl(pdsService.serviceEndpoint);
|
|
137
|
+
return pdsService.serviceEndpoint;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
144
|
+
error: `Could not resolve DID document: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// For did:web, resolve from domain
|
|
149
|
+
if (did.startsWith('did:web:')) {
|
|
150
|
+
const domain = did.replace('did:web:', '').replace(/:/g, '/');
|
|
151
|
+
const didDocUrl = `https://${domain}/.well-known/did.json`;
|
|
152
|
+
try {
|
|
153
|
+
validateUrl(didDocUrl);
|
|
154
|
+
const response = await hardenedFetch(didDocUrl);
|
|
155
|
+
if (response.ok) {
|
|
156
|
+
const didDoc = await response.json();
|
|
157
|
+
const parsed = didDocumentGuard.safeParse(didDoc);
|
|
158
|
+
if (parsed.success) {
|
|
159
|
+
const pdsService = parsed.data.service?.find((s) => s.type === 'AtprotoPersonalDataServer');
|
|
160
|
+
if (pdsService?.serviceEndpoint) {
|
|
161
|
+
validateUrl(pdsService.serviceEndpoint);
|
|
162
|
+
return pdsService.serviceEndpoint;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
169
|
+
error: `Could not resolve DID document: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
174
|
+
error: `Unsupported DID method or PDS not found for: ${did}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolve handle or DID to PDS endpoint
|
|
179
|
+
*/
|
|
180
|
+
export async function resolvePDS(identifier) {
|
|
181
|
+
// If it's already a DID, resolve directly
|
|
182
|
+
if (identifier.startsWith('did:')) {
|
|
183
|
+
return resolvePDSFromDID(identifier);
|
|
184
|
+
}
|
|
185
|
+
// Otherwise, treat as handle and resolve to DID first
|
|
186
|
+
const did = await resolveHandleToDID(identifier);
|
|
187
|
+
return resolvePDSFromDID(did);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Fetch authorization server metadata from PDS
|
|
191
|
+
*/
|
|
192
|
+
export async function fetchAuthorizationServerMetadata(pdsUrl) {
|
|
193
|
+
// First try protected resource metadata
|
|
194
|
+
const resourceMetadataUrl = `${pdsUrl}${WELL_KNOWN_PATHS.OAUTH_PROTECTED_RESOURCE}`;
|
|
195
|
+
try {
|
|
196
|
+
validateUrl(resourceMetadataUrl);
|
|
197
|
+
const resourceResponse = await hardenedFetch(resourceMetadataUrl);
|
|
198
|
+
if (resourceResponse.ok) {
|
|
199
|
+
const resourceMetadata = await resourceResponse.json();
|
|
200
|
+
const parsed = resourceServerMetadataGuard.safeParse(resourceMetadata);
|
|
201
|
+
if (parsed.success && parsed.data.authorization_servers.length > 0) {
|
|
202
|
+
// Use first authorization server
|
|
203
|
+
const authServerBase = parsed.data.authorization_servers[0];
|
|
204
|
+
if (authServerBase) {
|
|
205
|
+
// Construct full metadata URL
|
|
206
|
+
const authServerUrl = `${authServerBase}${WELL_KNOWN_PATHS.OAUTH_AUTHORIZATION_SERVER}`;
|
|
207
|
+
return fetchAuthorizationServerMetadataFromUrl(authServerUrl);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Fall through to try PDS directly as auth server
|
|
214
|
+
}
|
|
215
|
+
// Try PDS directly as authorization server
|
|
216
|
+
const authServerUrl = `${pdsUrl}${WELL_KNOWN_PATHS.OAUTH_AUTHORIZATION_SERVER}`;
|
|
217
|
+
return fetchAuthorizationServerMetadataFromUrl(authServerUrl);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Fetch authorization server metadata from specific URL
|
|
221
|
+
*/
|
|
222
|
+
async function fetchAuthorizationServerMetadataFromUrl(authServerUrl) {
|
|
223
|
+
validateUrl(authServerUrl);
|
|
224
|
+
const response = await hardenedFetch(authServerUrl);
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
throw new ConnectorError(ConnectorErrorCodes.General, {
|
|
227
|
+
error: `Failed to fetch authorization server metadata: ${response.status} ${response.statusText}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const metadata = await response.json();
|
|
231
|
+
const parsed = authorizationServerMetadataGuard.safeParse(metadata);
|
|
232
|
+
if (!parsed.success) {
|
|
233
|
+
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, {
|
|
234
|
+
error: 'Invalid authorization server metadata format',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Validate that atproto scope is supported
|
|
238
|
+
const scopesSupported = parsed.data.scopes_supported;
|
|
239
|
+
if (typeof scopesSupported === 'string') {
|
|
240
|
+
const scopes = scopesSupported.split(' ');
|
|
241
|
+
if (!scopes.includes('atproto')) {
|
|
242
|
+
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
|
|
243
|
+
error: 'Authorization server does not support atproto scope',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return parsed.data;
|
|
248
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pds-discovery.test.d.ts","sourceRoot":"","sources":["../src/pds-discovery.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|