@openverifiable/connector-bluesky 1.0.0 → 1.0.2
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/client-assertion.d.ts +8 -2
- package/lib/client-assertion.d.ts.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +74 -10
- package/lib/types.d.ts +3 -3
- package/package.json +7 -6
- package/lib/__fixtures__/did-documents.js +0 -56
- package/lib/__fixtures__/metadata.js +0 -56
- package/lib/__fixtures__/oauth-errors.js +0 -41
- package/lib/__fixtures__/profile-responses.js +0 -28
- package/lib/__fixtures__/token-responses.js +0 -33
- package/lib/__tests__/integration/real-account-helpers.js +0 -84
- package/lib/__tests__/integration/real-account.test.js +0 -118
- package/lib/client-assertion.js +0 -50
- package/lib/client-assertion.test.js +0 -234
- package/lib/constant.js +0 -77
- package/lib/dpop.js +0 -138
- package/lib/dpop.test.js +0 -266
- package/lib/index.test.js +0 -329
- package/lib/mock.js +0 -24
- package/lib/pds-discovery.js +0 -248
- package/lib/pds-discovery.test.js +0 -281
- package/lib/pkce.js +0 -46
- package/lib/pkce.test.js +0 -117
- package/lib/test-utils.js +0 -132
- package/lib/types.js +0 -95
package/lib/pds-discovery.js
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PDS Discovery Tests
|
|
3
|
-
* Tests for PDS resolution, DID resolution, metadata fetching, and SSRF protection
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
6
|
-
import nock from 'nock';
|
|
7
|
-
import { resolveHandleToDID, resolvePDSFromDID, resolvePDS, fetchAuthorizationServerMetadata, } from './pds-discovery.js';
|
|
8
|
-
import { ConnectorError } from '@logto/connector-kit';
|
|
9
|
-
import { didPlcDocument, didWebDocument, didDocumentWithHandle, } from './__fixtures__/did-documents.js';
|
|
10
|
-
import { authorizationServerMetadata, authorizationServerMetadataWithoutAtproto, resourceServerMetadataWithEntryway, } from './__fixtures__/metadata.js';
|
|
11
|
-
describe('PDS Discovery', () => {
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
nock.cleanAll();
|
|
14
|
-
});
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
nock.cleanAll();
|
|
17
|
-
});
|
|
18
|
-
describe('resolveHandleToDID', () => {
|
|
19
|
-
it('should resolve handle to DID via .well-known/atproto-did', async () => {
|
|
20
|
-
const handle = 'example.bsky.social';
|
|
21
|
-
const did = 'did:plc:example123456789';
|
|
22
|
-
nock('https://example.bsky.social')
|
|
23
|
-
.get('/.well-known/atproto-did')
|
|
24
|
-
.reply(200, did);
|
|
25
|
-
const result = await resolveHandleToDID(handle);
|
|
26
|
-
expect(result).toBe(did);
|
|
27
|
-
});
|
|
28
|
-
it('should handle handle with @ prefix', async () => {
|
|
29
|
-
const handle = '@example.bsky.social';
|
|
30
|
-
const did = 'did:plc:example123456789';
|
|
31
|
-
nock('https://example.bsky.social')
|
|
32
|
-
.get('/.well-known/atproto-did')
|
|
33
|
-
.reply(200, did);
|
|
34
|
-
const result = await resolveHandleToDID(handle);
|
|
35
|
-
expect(result).toBe(did);
|
|
36
|
-
});
|
|
37
|
-
it('should trim whitespace from DID response', async () => {
|
|
38
|
-
const handle = 'example.bsky.social';
|
|
39
|
-
const did = ' did:plc:example123456789 ';
|
|
40
|
-
nock('https://example.bsky.social')
|
|
41
|
-
.get('/.well-known/atproto-did')
|
|
42
|
-
.reply(200, did);
|
|
43
|
-
const result = await resolveHandleToDID(handle);
|
|
44
|
-
expect(result).toBe('did:plc:example123456789');
|
|
45
|
-
});
|
|
46
|
-
it('should throw error when handle cannot be resolved', async () => {
|
|
47
|
-
const handle = 'nonexistent.bsky.social';
|
|
48
|
-
nock('https://nonexistent.bsky.social')
|
|
49
|
-
.get('/.well-known/atproto-did')
|
|
50
|
-
.reply(404);
|
|
51
|
-
await expect(resolveHandleToDID(handle)).rejects.toThrow(ConnectorError);
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
describe('resolvePDSFromDID', () => {
|
|
55
|
-
it('should resolve did:plc to PDS endpoint via plc.directory', async () => {
|
|
56
|
-
const did = 'did:plc:example123456789';
|
|
57
|
-
const pdsUrl = 'https://bsky.social';
|
|
58
|
-
nock('https://plc.directory')
|
|
59
|
-
.get('/example123456789')
|
|
60
|
-
.reply(200, didPlcDocument);
|
|
61
|
-
const result = await resolvePDSFromDID(did);
|
|
62
|
-
expect(result).toBe(pdsUrl);
|
|
63
|
-
});
|
|
64
|
-
it('should resolve did:web to PDS endpoint via domain', async () => {
|
|
65
|
-
const did = 'did:web:example.com';
|
|
66
|
-
const pdsUrl = 'https://pds.example.com';
|
|
67
|
-
nock('https://example.com')
|
|
68
|
-
.get('/.well-known/did.json')
|
|
69
|
-
.reply(200, didWebDocument);
|
|
70
|
-
const result = await resolvePDSFromDID(did);
|
|
71
|
-
expect(result).toBe(pdsUrl);
|
|
72
|
-
});
|
|
73
|
-
it('should handle did:web with path segments', async () => {
|
|
74
|
-
const did = 'did:web:example.com:user:alice';
|
|
75
|
-
const didDocWithoutPDS = {
|
|
76
|
-
id: did,
|
|
77
|
-
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
78
|
-
// No service field - should fail
|
|
79
|
-
};
|
|
80
|
-
nock('https://example.com')
|
|
81
|
-
.get('/user/alice/.well-known/did.json')
|
|
82
|
-
.reply(200, didDocWithoutPDS);
|
|
83
|
-
// This should fail because the DID document doesn't have a PDS service
|
|
84
|
-
await expect(resolvePDSFromDID(did)).rejects.toThrow(ConnectorError);
|
|
85
|
-
});
|
|
86
|
-
it('should throw error when DID document has no PDS service', async () => {
|
|
87
|
-
const did = 'did:plc:example123456789';
|
|
88
|
-
const didDocWithoutPDS = {
|
|
89
|
-
id: did,
|
|
90
|
-
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
91
|
-
// No service field
|
|
92
|
-
};
|
|
93
|
-
nock('https://plc.directory')
|
|
94
|
-
.get('/example123456789')
|
|
95
|
-
.reply(200, didDocWithoutPDS);
|
|
96
|
-
await expect(resolvePDSFromDID(did)).rejects.toThrow(ConnectorError);
|
|
97
|
-
});
|
|
98
|
-
it('should throw error for unsupported DID method', async () => {
|
|
99
|
-
const did = 'did:key:example123456789';
|
|
100
|
-
await expect(resolvePDSFromDID(did)).rejects.toThrow(ConnectorError);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
describe('resolvePDS', () => {
|
|
104
|
-
it('should resolve DID directly to PDS', async () => {
|
|
105
|
-
const did = 'did:plc:example123456789';
|
|
106
|
-
nock('https://plc.directory')
|
|
107
|
-
.get('/example123456789')
|
|
108
|
-
.reply(200, didPlcDocument);
|
|
109
|
-
const result = await resolvePDS(did);
|
|
110
|
-
expect(result).toBe('https://bsky.social');
|
|
111
|
-
});
|
|
112
|
-
it('should resolve handle to DID then to PDS', async () => {
|
|
113
|
-
const handle = 'example.bsky.social';
|
|
114
|
-
const did = 'did:plc:example123456789';
|
|
115
|
-
nock('https://example.bsky.social')
|
|
116
|
-
.get('/.well-known/atproto-did')
|
|
117
|
-
.reply(200, did);
|
|
118
|
-
nock('https://plc.directory')
|
|
119
|
-
.get('/example123456789')
|
|
120
|
-
.reply(200, didPlcDocument);
|
|
121
|
-
const result = await resolvePDS(handle);
|
|
122
|
-
expect(result).toBe('https://bsky.social');
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
describe('fetchAuthorizationServerMetadata', () => {
|
|
126
|
-
it('should fetch metadata from protected resource metadata first', async () => {
|
|
127
|
-
const pdsUrl = 'https://pds.example.com';
|
|
128
|
-
nock('https://pds.example.com')
|
|
129
|
-
.get('/.well-known/oauth-protected-resource')
|
|
130
|
-
.reply(200, resourceServerMetadataWithEntryway);
|
|
131
|
-
// Mock the entryway authorization server metadata
|
|
132
|
-
// The function now constructs the full URL with the well-known path
|
|
133
|
-
const entrywayBase = 'https://entryway.example.com';
|
|
134
|
-
// Mock the actual metadata endpoint
|
|
135
|
-
nock(entrywayBase)
|
|
136
|
-
.get('/.well-known/oauth-authorization-server')
|
|
137
|
-
.reply(200, {
|
|
138
|
-
...authorizationServerMetadata,
|
|
139
|
-
issuer: entrywayBase,
|
|
140
|
-
});
|
|
141
|
-
const result = await fetchAuthorizationServerMetadata(pdsUrl);
|
|
142
|
-
expect(result.issuer).toBe(entrywayBase);
|
|
143
|
-
});
|
|
144
|
-
it('should fall back to PDS as authorization server', async () => {
|
|
145
|
-
const pdsUrl = 'https://pds.example.com';
|
|
146
|
-
nock('https://pds.example.com')
|
|
147
|
-
.get('/.well-known/oauth-protected-resource')
|
|
148
|
-
.reply(404);
|
|
149
|
-
nock('https://pds.example.com')
|
|
150
|
-
.get('/.well-known/oauth-authorization-server')
|
|
151
|
-
.reply(200, authorizationServerMetadata);
|
|
152
|
-
const result = await fetchAuthorizationServerMetadata(pdsUrl);
|
|
153
|
-
expect(result.issuer).toBe('https://bsky.social');
|
|
154
|
-
});
|
|
155
|
-
it('should validate atproto scope is supported', async () => {
|
|
156
|
-
const pdsUrl = 'https://pds.example.com';
|
|
157
|
-
nock('https://pds.example.com')
|
|
158
|
-
.get('/.well-known/oauth-protected-resource')
|
|
159
|
-
.reply(404);
|
|
160
|
-
nock('https://pds.example.com')
|
|
161
|
-
.get('/.well-known/oauth-authorization-server')
|
|
162
|
-
.reply(200, authorizationServerMetadataWithoutAtproto);
|
|
163
|
-
await expect(fetchAuthorizationServerMetadata(pdsUrl)).rejects.toThrow(ConnectorError);
|
|
164
|
-
});
|
|
165
|
-
it('should throw error for invalid metadata format', async () => {
|
|
166
|
-
const pdsUrl = 'https://pds.example.com';
|
|
167
|
-
nock('https://pds.example.com')
|
|
168
|
-
.get('/.well-known/oauth-protected-resource')
|
|
169
|
-
.reply(404);
|
|
170
|
-
nock('https://pds.example.com')
|
|
171
|
-
.get('/.well-known/oauth-authorization-server')
|
|
172
|
-
.reply(200, { invalid: 'metadata' });
|
|
173
|
-
await expect(fetchAuthorizationServerMetadata(pdsUrl)).rejects.toThrow(ConnectorError);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
describe('SSRF Protection', () => {
|
|
177
|
-
it('should reject localhost URLs', async () => {
|
|
178
|
-
await expect(resolveHandleToDID('localhost')).rejects.toThrow(ConnectorError);
|
|
179
|
-
await expect(resolvePDSFromDID('did:plc:test')).rejects.toThrow(ConnectorError);
|
|
180
|
-
});
|
|
181
|
-
it('should reject 127.0.0.1 URLs', async () => {
|
|
182
|
-
await expect(resolveHandleToDID('127.0.0.1')).rejects.toThrow(ConnectorError);
|
|
183
|
-
});
|
|
184
|
-
it('should reject IPv6 localhost', async () => {
|
|
185
|
-
await expect(resolveHandleToDID('::1')).rejects.toThrow(ConnectorError);
|
|
186
|
-
});
|
|
187
|
-
it('should reject private IP ranges (192.168.x.x)', async () => {
|
|
188
|
-
await expect(resolveHandleToDID('192.168.1.1')).rejects.toThrow(ConnectorError);
|
|
189
|
-
await expect(resolveHandleToDID('192.168.0.1')).rejects.toThrow(ConnectorError);
|
|
190
|
-
});
|
|
191
|
-
it('should reject private IP ranges (10.x.x.x)', async () => {
|
|
192
|
-
await expect(resolveHandleToDID('10.0.0.1')).rejects.toThrow(ConnectorError);
|
|
193
|
-
await expect(resolveHandleToDID('10.255.255.255')).rejects.toThrow(ConnectorError);
|
|
194
|
-
});
|
|
195
|
-
it('should reject private IP ranges (172.16-31.x.x)', async () => {
|
|
196
|
-
await expect(resolveHandleToDID('172.16.0.1')).rejects.toThrow(ConnectorError);
|
|
197
|
-
await expect(resolveHandleToDID('172.31.255.255')).rejects.toThrow(ConnectorError);
|
|
198
|
-
await expect(resolveHandleToDID('172.20.10.1')).rejects.toThrow(ConnectorError);
|
|
199
|
-
});
|
|
200
|
-
it('should reject link-local addresses (169.254.x.x)', async () => {
|
|
201
|
-
await expect(resolveHandleToDID('169.254.0.1')).rejects.toThrow(ConnectorError);
|
|
202
|
-
});
|
|
203
|
-
it('should reject IPv6 link-local addresses', async () => {
|
|
204
|
-
await expect(resolveHandleToDID('fe80::1')).rejects.toThrow(ConnectorError);
|
|
205
|
-
});
|
|
206
|
-
it('should reject non-HTTPS URLs', async () => {
|
|
207
|
-
// This is tested indirectly through validateUrl
|
|
208
|
-
// HTTP URLs should be rejected
|
|
209
|
-
const httpUrl = 'http://example.com';
|
|
210
|
-
await expect(fetchAuthorizationServerMetadata(httpUrl)).rejects.toThrow(ConnectorError);
|
|
211
|
-
});
|
|
212
|
-
it('should accept valid public HTTPS URLs', async () => {
|
|
213
|
-
const pdsUrl = 'https://bsky.social';
|
|
214
|
-
nock('https://bsky.social')
|
|
215
|
-
.get('/.well-known/oauth-protected-resource')
|
|
216
|
-
.reply(404);
|
|
217
|
-
nock('https://bsky.social')
|
|
218
|
-
.get('/.well-known/oauth-authorization-server')
|
|
219
|
-
.reply(200, authorizationServerMetadata);
|
|
220
|
-
const result = await fetchAuthorizationServerMetadata(pdsUrl);
|
|
221
|
-
expect(result).toBeDefined();
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
describe('Error Handling', () => {
|
|
225
|
-
it('should handle network errors gracefully', async () => {
|
|
226
|
-
const handle = 'example.bsky.social';
|
|
227
|
-
nock('https://example.bsky.social')
|
|
228
|
-
.get('/.well-known/atproto-did')
|
|
229
|
-
.replyWithError('Network error');
|
|
230
|
-
await expect(resolveHandleToDID(handle)).rejects.toThrow(ConnectorError);
|
|
231
|
-
});
|
|
232
|
-
it('should handle timeout errors', async () => {
|
|
233
|
-
const handle = 'example.bsky.social';
|
|
234
|
-
// Simulate timeout by delaying the response body
|
|
235
|
-
nock('https://example.bsky.social')
|
|
236
|
-
.get('/.well-known/atproto-did')
|
|
237
|
-
.delayBody(15000) // Longer than HTTP timeout (10s)
|
|
238
|
-
.reply(200, 'did:plc:test');
|
|
239
|
-
await expect(resolveHandleToDID(handle)).rejects.toThrow();
|
|
240
|
-
}, 20000); // Increase test timeout to allow for the delay
|
|
241
|
-
it('should handle invalid JSON responses', async () => {
|
|
242
|
-
const did = 'did:plc:example123456789';
|
|
243
|
-
nock('https://plc.directory')
|
|
244
|
-
.get('/example123456789')
|
|
245
|
-
.reply(200, 'invalid json {');
|
|
246
|
-
await expect(resolvePDSFromDID(did)).rejects.toThrow();
|
|
247
|
-
});
|
|
248
|
-
it('should handle missing required fields in DID document', async () => {
|
|
249
|
-
const did = 'did:plc:example123456789';
|
|
250
|
-
const invalidDoc = {
|
|
251
|
-
id: did,
|
|
252
|
-
// Missing service field
|
|
253
|
-
};
|
|
254
|
-
nock('https://plc.directory')
|
|
255
|
-
.get('/example123456789')
|
|
256
|
-
.reply(200, invalidDoc);
|
|
257
|
-
await expect(resolvePDSFromDID(did)).rejects.toThrow(ConnectorError);
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
describe('Bidirectional Handle Verification', () => {
|
|
261
|
-
it('should verify handle is claimed in DID document', async () => {
|
|
262
|
-
// This is a critical security test
|
|
263
|
-
// The handle resolution should verify that the DID document
|
|
264
|
-
// actually claims the handle via alsoKnownAs field
|
|
265
|
-
const handle = 'example.bsky.social';
|
|
266
|
-
const did = 'did:plc:example123456789';
|
|
267
|
-
nock('https://example.bsky.social')
|
|
268
|
-
.get('/.well-known/atproto-did')
|
|
269
|
-
.reply(200, did);
|
|
270
|
-
nock('https://plc.directory')
|
|
271
|
-
.get('/example123456789')
|
|
272
|
-
.reply(200, didDocumentWithHandle);
|
|
273
|
-
// The implementation should verify that the DID document
|
|
274
|
-
// includes the handle in alsoKnownAs
|
|
275
|
-
// Note: This verification is not currently implemented in the code,
|
|
276
|
-
// but should be added for security
|
|
277
|
-
const result = await resolvePDS(handle);
|
|
278
|
-
expect(result).toBeDefined();
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
});
|
package/lib/pkce.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
|
-
import { randomBytes } from 'crypto';
|
|
3
|
-
import { PKCE_CONFIG } from './constant.js';
|
|
4
|
-
/**
|
|
5
|
-
* Generate a random code verifier for PKCE (RFC 7636)
|
|
6
|
-
* Returns base64url-encoded string of 32-96 random bytes
|
|
7
|
-
*/
|
|
8
|
-
export function generateCodeVerifier() {
|
|
9
|
-
// Generate 32 random bytes (256 bits) for good security
|
|
10
|
-
const bytes = randomBytes(32);
|
|
11
|
-
// Convert to base64url (URL-safe base64)
|
|
12
|
-
return base64UrlEncode(bytes);
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Generate code challenge from verifier using S256 method
|
|
16
|
-
* S256 = SHA256(code_verifier) encoded as base64url
|
|
17
|
-
*/
|
|
18
|
-
export function generateCodeChallenge(verifier) {
|
|
19
|
-
const hash = createHash('sha256');
|
|
20
|
-
hash.update(verifier);
|
|
21
|
-
const digest = hash.digest();
|
|
22
|
-
return base64UrlEncode(digest);
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Generate PKCE code pair (verifier + challenge)
|
|
26
|
-
*/
|
|
27
|
-
export function generatePKCECodePair() {
|
|
28
|
-
const codeVerifier = generateCodeVerifier();
|
|
29
|
-
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
30
|
-
return {
|
|
31
|
-
codeVerifier,
|
|
32
|
-
codeChallenge,
|
|
33
|
-
codeChallengeMethod: PKCE_CONFIG.codeChallengeMethod,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Base64url encode (URL-safe base64)
|
|
38
|
-
* Replaces + with -, / with _, and removes padding =
|
|
39
|
-
*/
|
|
40
|
-
function base64UrlEncode(buffer) {
|
|
41
|
-
return buffer
|
|
42
|
-
.toString('base64')
|
|
43
|
-
.replace(/\+/g, '-')
|
|
44
|
-
.replace(/\//g, '_')
|
|
45
|
-
.replace(/=/g, '');
|
|
46
|
-
}
|