@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,281 @@
|
|
|
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.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PKCECodePair } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a random code verifier for PKCE (RFC 7636)
|
|
4
|
+
* Returns base64url-encoded string of 32-96 random bytes
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateCodeVerifier(): string;
|
|
7
|
+
/**
|
|
8
|
+
* Generate code challenge from verifier using S256 method
|
|
9
|
+
* S256 = SHA256(code_verifier) encoded as base64url
|
|
10
|
+
*/
|
|
11
|
+
export declare function generateCodeChallenge(verifier: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Generate PKCE code pair (verifier + challenge)
|
|
14
|
+
*/
|
|
15
|
+
export declare function generatePKCECodePair(): PKCECodePair;
|
|
16
|
+
//# sourceMappingURL=pkce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../src/pkce.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAK7C;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAK9D;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,YAAY,CASnD"}
|
package/lib/pkce.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.test.d.ts","sourceRoot":"","sources":["../src/pkce.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
package/lib/pkce.test.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE Tests
|
|
3
|
+
* Tests for Proof Key for Code Exchange (RFC 7636) implementation
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from '@jest/globals';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { generateCodeVerifier, generateCodeChallenge, generatePKCECodePair, } from './pkce.js';
|
|
8
|
+
import { base64UrlEncode, sha256Base64Url } from './test-utils.js';
|
|
9
|
+
describe('PKCE Implementation', () => {
|
|
10
|
+
describe('generateCodeVerifier', () => {
|
|
11
|
+
it('should generate code verifier with correct length (43+ characters)', () => {
|
|
12
|
+
const verifier = generateCodeVerifier();
|
|
13
|
+
expect(verifier).toBeDefined();
|
|
14
|
+
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|
15
|
+
expect(typeof verifier).toBe('string');
|
|
16
|
+
});
|
|
17
|
+
it('should generate base64url-safe encoding (no +, /, or =)', () => {
|
|
18
|
+
const verifier = generateCodeVerifier();
|
|
19
|
+
expect(verifier).not.toContain('+');
|
|
20
|
+
expect(verifier).not.toContain('/');
|
|
21
|
+
expect(verifier).not.toContain('=');
|
|
22
|
+
// Should only contain base64url-safe characters
|
|
23
|
+
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
24
|
+
});
|
|
25
|
+
it('should generate unique code verifiers each time', () => {
|
|
26
|
+
const verifiers = new Set();
|
|
27
|
+
const iterations = 100;
|
|
28
|
+
for (let i = 0; i < iterations; i++) {
|
|
29
|
+
verifiers.add(generateCodeVerifier());
|
|
30
|
+
}
|
|
31
|
+
// All verifiers should be unique
|
|
32
|
+
expect(verifiers.size).toBe(iterations);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('generateCodeChallenge', () => {
|
|
36
|
+
it('should generate S256 code challenge from verifier', () => {
|
|
37
|
+
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
38
|
+
const challenge = generateCodeChallenge(verifier);
|
|
39
|
+
// Expected challenge for the example verifier from RFC 7636
|
|
40
|
+
const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
41
|
+
expect(challenge).toBe(expected);
|
|
42
|
+
});
|
|
43
|
+
it('should produce base64url-safe encoding', () => {
|
|
44
|
+
const verifier = generateCodeVerifier();
|
|
45
|
+
const challenge = generateCodeChallenge(verifier);
|
|
46
|
+
expect(challenge).not.toContain('+');
|
|
47
|
+
expect(challenge).not.toContain('/');
|
|
48
|
+
expect(challenge).not.toContain('=');
|
|
49
|
+
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
50
|
+
});
|
|
51
|
+
it('should generate consistent challenge for same verifier', () => {
|
|
52
|
+
const verifier = generateCodeVerifier();
|
|
53
|
+
const challenge1 = generateCodeChallenge(verifier);
|
|
54
|
+
const challenge2 = generateCodeChallenge(verifier);
|
|
55
|
+
expect(challenge1).toBe(challenge2);
|
|
56
|
+
});
|
|
57
|
+
it('should generate different challenges for different verifiers', () => {
|
|
58
|
+
const verifier1 = generateCodeVerifier();
|
|
59
|
+
const verifier2 = generateCodeVerifier();
|
|
60
|
+
const challenge1 = generateCodeChallenge(verifier1);
|
|
61
|
+
const challenge2 = generateCodeChallenge(verifier2);
|
|
62
|
+
expect(challenge1).not.toBe(challenge2);
|
|
63
|
+
});
|
|
64
|
+
it('should use SHA256 hash for S256 method', () => {
|
|
65
|
+
const verifier = 'test_verifier_12345';
|
|
66
|
+
const challenge = generateCodeChallenge(verifier);
|
|
67
|
+
// Manually compute expected hash
|
|
68
|
+
const hash = createHash('sha256');
|
|
69
|
+
hash.update(verifier);
|
|
70
|
+
const digest = hash.digest();
|
|
71
|
+
const expected = base64UrlEncode(digest);
|
|
72
|
+
expect(challenge).toBe(expected);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('generatePKCECodePair', () => {
|
|
76
|
+
it('should generate valid PKCE code pair', () => {
|
|
77
|
+
const pkce = generatePKCECodePair();
|
|
78
|
+
expect(pkce.codeVerifier).toBeDefined();
|
|
79
|
+
expect(pkce.codeChallenge).toBeDefined();
|
|
80
|
+
expect(pkce.codeChallengeMethod).toBe('S256');
|
|
81
|
+
expect(pkce.codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
82
|
+
expect(pkce.codeChallenge.length).toBeGreaterThanOrEqual(43);
|
|
83
|
+
});
|
|
84
|
+
it('should generate different code pairs each time', () => {
|
|
85
|
+
const pkce1 = generatePKCECodePair();
|
|
86
|
+
const pkce2 = generatePKCECodePair();
|
|
87
|
+
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
|
|
88
|
+
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
|
|
89
|
+
});
|
|
90
|
+
it('should generate challenge that matches verifier', () => {
|
|
91
|
+
const pkce = generatePKCECodePair();
|
|
92
|
+
const expectedChallenge = generateCodeChallenge(pkce.codeVerifier);
|
|
93
|
+
expect(pkce.codeChallenge).toBe(expectedChallenge);
|
|
94
|
+
});
|
|
95
|
+
it('should use S256 challenge method', () => {
|
|
96
|
+
const pkce = generatePKCECodePair();
|
|
97
|
+
expect(pkce.codeChallengeMethod).toBe('S256');
|
|
98
|
+
// Verify it's actually S256 (SHA256 + base64url)
|
|
99
|
+
const expectedChallenge = sha256Base64Url(pkce.codeVerifier);
|
|
100
|
+
expect(pkce.codeChallenge).toBe(expectedChallenge);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('Code Verifier to Challenge Relationship', () => {
|
|
104
|
+
it('should verify challenge can be derived from verifier', () => {
|
|
105
|
+
const pkce = generatePKCECodePair();
|
|
106
|
+
const derivedChallenge = generateCodeChallenge(pkce.codeVerifier);
|
|
107
|
+
expect(derivedChallenge).toBe(pkce.codeChallenge);
|
|
108
|
+
});
|
|
109
|
+
it('should handle verifier-to-challenge round trip', () => {
|
|
110
|
+
const verifier = generateCodeVerifier();
|
|
111
|
+
const challenge = generateCodeChallenge(verifier);
|
|
112
|
+
// Challenge should be deterministic from verifier
|
|
113
|
+
const challenge2 = generateCodeChallenge(verifier);
|
|
114
|
+
expect(challenge).toBe(challenge2);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Utilities
|
|
3
|
+
* Shared helper functions for testing
|
|
4
|
+
*/
|
|
5
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
6
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
7
|
+
import { type JWTPayload } from 'jose';
|
|
8
|
+
import type { DPoPKeyPair } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Base64url encode (URL-safe base64)
|
|
11
|
+
*/
|
|
12
|
+
export declare function base64UrlEncode(buffer: Buffer): string;
|
|
13
|
+
/**
|
|
14
|
+
* Base64url decode
|
|
15
|
+
*/
|
|
16
|
+
export declare function base64UrlDecode(str: string): Buffer;
|
|
17
|
+
/**
|
|
18
|
+
* Verify JWT signature and return payload
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifyJWT(jwt: string, publicKey: CryptoKey): Promise<JWTPayload>;
|
|
21
|
+
/**
|
|
22
|
+
* Verify DPoP JWT signature
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifyDPoPJWT(jwt: string, keyPair: DPoPKeyPair): Promise<JWTPayload>;
|
|
25
|
+
/**
|
|
26
|
+
* Generate SHA256 hash and base64url encode (same as S256 PKCE challenge)
|
|
27
|
+
*/
|
|
28
|
+
export declare function sha256Base64Url(input: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Generate test ES256 key pair
|
|
31
|
+
*/
|
|
32
|
+
export declare function generateTestKeyPair(): Promise<DPoPKeyPair>;
|
|
33
|
+
/**
|
|
34
|
+
* Export public key as JWK
|
|
35
|
+
*/
|
|
36
|
+
export declare function exportPublicKeyAsJWK(publicKey: CryptoKey): Promise<Record<string, unknown>>;
|
|
37
|
+
/**
|
|
38
|
+
* Create mock HTTP response headers
|
|
39
|
+
*/
|
|
40
|
+
export declare function createMockHeaders(headers?: Record<string, string>): Record<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* Create mock HTTP response with DPoP nonce
|
|
43
|
+
*/
|
|
44
|
+
export declare function createMockResponseWithNonce(body: unknown, nonce: string): {
|
|
45
|
+
statusCode: number;
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
body: string;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Create mock HTTP error response with use_dpop_nonce
|
|
51
|
+
*/
|
|
52
|
+
export declare function createMockDPoPNonceError(nonce: string, statusCode?: number): {
|
|
53
|
+
statusCode: number;
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
body: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Sleep utility for testing async behavior
|
|
59
|
+
*/
|
|
60
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Generate random string for testing
|
|
63
|
+
*/
|
|
64
|
+
export declare function randomString(length?: number): string;
|
|
65
|
+
//# sourceMappingURL=test-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;;;AAGH,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAMtD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAOnD;AAED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,SAAS,GACnB,OAAO,CAAC,UAAU,CAAC,CAGrB;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,UAAU,CAAC,CAErB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKrD;AAED;;GAEG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,WAAW,CAAC,CAgBhE;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,SAAS,GACnB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAOlC;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACnC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAKxB;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,MAAM,GACZ;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CASvE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,MAAM,EACb,UAAU,SAAM,GACf;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAcvE;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAK,GAAG,MAAM,CAOhD"}
|