@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/dpop.test.js
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DPoP Tests
|
|
3
|
-
* Tests for Demonstrating Proof of Possession (RFC 9449) implementation
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect } from '@jest/globals';
|
|
6
|
-
import { generateDPoPKeyPair, exportPublicKeyAsJWK, createDPoPProofForToken, createDPoPProofForResource, extractDPoPNonce, } from './dpop.js';
|
|
7
|
-
import { sha256Base64Url, createMockDPoPNonceError, } from './test-utils.js';
|
|
8
|
-
describe('DPoP Implementation', () => {
|
|
9
|
-
describe('generateDPoPKeyPair', () => {
|
|
10
|
-
it('should generate valid DPoP key pair', async () => {
|
|
11
|
-
const keyPair = await generateDPoPKeyPair();
|
|
12
|
-
expect(keyPair.publicKey).toBeDefined();
|
|
13
|
-
expect(keyPair.privateKey).toBeDefined();
|
|
14
|
-
expect(keyPair.publicKey).toBeInstanceOf(CryptoKey);
|
|
15
|
-
expect(keyPair.privateKey).toBeInstanceOf(CryptoKey);
|
|
16
|
-
});
|
|
17
|
-
it('should generate ES256 key pairs with P-256 curve', async () => {
|
|
18
|
-
const keyPair = await generateDPoPKeyPair();
|
|
19
|
-
const crypto = globalThis.crypto;
|
|
20
|
-
// Verify key algorithm
|
|
21
|
-
const publicKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
22
|
-
expect(publicKeyJwk.kty).toBe('EC');
|
|
23
|
-
expect(publicKeyJwk.crv).toBe('P-256');
|
|
24
|
-
// Note: alg is not part of JWK standard, it's inferred from the key type
|
|
25
|
-
});
|
|
26
|
-
it('should generate different key pairs each time', async () => {
|
|
27
|
-
const keyPair1 = await generateDPoPKeyPair();
|
|
28
|
-
const keyPair2 = await generateDPoPKeyPair();
|
|
29
|
-
// Export and compare
|
|
30
|
-
const crypto = globalThis.crypto;
|
|
31
|
-
const jwk1 = await crypto.subtle.exportKey('jwk', keyPair1.privateKey);
|
|
32
|
-
const jwk2 = await crypto.subtle.exportKey('jwk', keyPair2.privateKey);
|
|
33
|
-
expect(jwk1.d).not.toBe(jwk2.d);
|
|
34
|
-
});
|
|
35
|
-
it('should generate extractable keys', async () => {
|
|
36
|
-
const keyPair = await generateDPoPKeyPair();
|
|
37
|
-
const crypto = globalThis.crypto;
|
|
38
|
-
// Should be able to export keys
|
|
39
|
-
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
40
|
-
const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
41
|
-
expect(publicJwk).toBeDefined();
|
|
42
|
-
expect(privateJwk).toBeDefined();
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
describe('exportPublicKeyAsJWK', () => {
|
|
46
|
-
it('should export public key as JWK without private fields', async () => {
|
|
47
|
-
const keyPair = await generateDPoPKeyPair();
|
|
48
|
-
const publicJwk = await exportPublicKeyAsJWK(keyPair.publicKey);
|
|
49
|
-
expect(publicJwk.kty).toBe('EC');
|
|
50
|
-
expect(publicJwk.crv).toBe('P-256');
|
|
51
|
-
expect(publicJwk.x).toBeDefined();
|
|
52
|
-
expect(publicJwk.y).toBeDefined();
|
|
53
|
-
expect(publicJwk.d).toBeUndefined(); // Private key field should not be present
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
describe('createDPoPProofForToken', () => {
|
|
57
|
-
it('should create valid DPoP proof JWT for token endpoint', async () => {
|
|
58
|
-
const keyPair = await generateDPoPKeyPair();
|
|
59
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
60
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
61
|
-
expect(proof).toBeDefined();
|
|
62
|
-
expect(typeof proof).toBe('string');
|
|
63
|
-
// Decode and verify structure
|
|
64
|
-
const parts = proof.split('.');
|
|
65
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
66
|
-
expect(decoded).toBeDefined();
|
|
67
|
-
});
|
|
68
|
-
it('should include required JWT header fields', async () => {
|
|
69
|
-
const keyPair = await generateDPoPKeyPair();
|
|
70
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
71
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
72
|
-
// Decode header
|
|
73
|
-
const parts = proof.split('.');
|
|
74
|
-
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
|
75
|
-
expect(header.typ).toBe('dpop+jwt');
|
|
76
|
-
expect(header.alg).toBe('ES256');
|
|
77
|
-
expect(header.jwk).toBeDefined();
|
|
78
|
-
expect(header.jwk.kty).toBe('EC');
|
|
79
|
-
expect(header.jwk.crv).toBe('P-256');
|
|
80
|
-
});
|
|
81
|
-
it('should include required JWT payload fields', async () => {
|
|
82
|
-
const keyPair = await generateDPoPKeyPair();
|
|
83
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
84
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
85
|
-
const parts = proof.split('.');
|
|
86
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
87
|
-
expect(decoded.jti).toBeDefined();
|
|
88
|
-
expect(decoded.htm).toBe('POST');
|
|
89
|
-
expect(decoded.htu).toBe(tokenEndpoint);
|
|
90
|
-
expect(decoded.iat).toBeDefined();
|
|
91
|
-
expect(decoded.exp).toBeDefined();
|
|
92
|
-
});
|
|
93
|
-
it('should include nonce when provided', async () => {
|
|
94
|
-
const keyPair = await generateDPoPKeyPair();
|
|
95
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
96
|
-
const nonce = 'test-nonce-12345';
|
|
97
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint, nonce);
|
|
98
|
-
const parts = proof.split('.');
|
|
99
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
100
|
-
expect(decoded.nonce).toBe(nonce);
|
|
101
|
-
});
|
|
102
|
-
it('should include unique jti in each proof', async () => {
|
|
103
|
-
const keyPair = await generateDPoPKeyPair();
|
|
104
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
105
|
-
const proof1 = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
106
|
-
const proof2 = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
107
|
-
const parts1 = proof1.split('.');
|
|
108
|
-
const parts2 = proof2.split('.');
|
|
109
|
-
const decoded1 = JSON.parse(Buffer.from(parts1[1], 'base64url').toString());
|
|
110
|
-
const decoded2 = JSON.parse(Buffer.from(parts2[1], 'base64url').toString());
|
|
111
|
-
expect(decoded1.jti).not.toBe(decoded2.jti);
|
|
112
|
-
});
|
|
113
|
-
it('should sign proof with correct private key', async () => {
|
|
114
|
-
const keyPair = await generateDPoPKeyPair();
|
|
115
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
116
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint);
|
|
117
|
-
// Verify signature can be validated by checking JWT structure
|
|
118
|
-
// Note: We can't verify with the same key pair because the public key
|
|
119
|
-
// from generateDPoPKeyPair only has 'sign' usage, not 'verify'
|
|
120
|
-
// In production, the server would verify using the public key from the JWT header
|
|
121
|
-
const parts = proof.split('.');
|
|
122
|
-
expect(parts.length).toBe(3); // header.payload.signature
|
|
123
|
-
expect(proof).toBeDefined();
|
|
124
|
-
});
|
|
125
|
-
it('should include correct HTTP method and URL', async () => {
|
|
126
|
-
const keyPair = await generateDPoPKeyPair();
|
|
127
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
128
|
-
const proof = await createDPoPProofForToken(keyPair, 'GET', tokenEndpoint);
|
|
129
|
-
const parts = proof.split('.');
|
|
130
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
131
|
-
expect(decoded.htm).toBe('GET');
|
|
132
|
-
expect(decoded.htu).toBe(tokenEndpoint);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
describe('createDPoPProofForResource', () => {
|
|
136
|
-
it('should create valid DPoP proof JWT for resource server', async () => {
|
|
137
|
-
const keyPair = await generateDPoPKeyPair();
|
|
138
|
-
const resourceUrl = 'https://bsky.social/xrpc/app.bsky.actor.getProfile';
|
|
139
|
-
const accessToken = 'test_access_token_12345';
|
|
140
|
-
const proof = await createDPoPProofForResource(keyPair, 'GET', resourceUrl, accessToken);
|
|
141
|
-
expect(proof).toBeDefined();
|
|
142
|
-
expect(typeof proof).toBe('string');
|
|
143
|
-
});
|
|
144
|
-
it('should include ath (access token hash) field', async () => {
|
|
145
|
-
const keyPair = await generateDPoPKeyPair();
|
|
146
|
-
const resourceUrl = 'https://bsky.social/xrpc/app.bsky.actor.getProfile';
|
|
147
|
-
const accessToken = 'test_access_token_12345';
|
|
148
|
-
const proof = await createDPoPProofForResource(keyPair, 'GET', resourceUrl, accessToken);
|
|
149
|
-
const parts = proof.split('.');
|
|
150
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
151
|
-
expect(decoded.ath).toBeDefined();
|
|
152
|
-
// Verify ath is correct hash of access token
|
|
153
|
-
const expectedAth = sha256Base64Url(accessToken);
|
|
154
|
-
expect(decoded.ath).toBe(expectedAth);
|
|
155
|
-
});
|
|
156
|
-
it('should include all required fields plus ath', async () => {
|
|
157
|
-
const keyPair = await generateDPoPKeyPair();
|
|
158
|
-
const resourceUrl = 'https://bsky.social/xrpc/app.bsky.actor.getProfile';
|
|
159
|
-
const accessToken = 'test_access_token_12345';
|
|
160
|
-
const proof = await createDPoPProofForResource(keyPair, 'GET', resourceUrl, accessToken);
|
|
161
|
-
const parts = proof.split('.');
|
|
162
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
163
|
-
expect(decoded.jti).toBeDefined();
|
|
164
|
-
expect(decoded.htm).toBe('GET');
|
|
165
|
-
expect(decoded.htu).toBe(resourceUrl);
|
|
166
|
-
expect(decoded.iat).toBeDefined();
|
|
167
|
-
expect(decoded.exp).toBeDefined();
|
|
168
|
-
expect(decoded.ath).toBeDefined();
|
|
169
|
-
});
|
|
170
|
-
it('should include nonce when provided', async () => {
|
|
171
|
-
const keyPair = await generateDPoPKeyPair();
|
|
172
|
-
const resourceUrl = 'https://bsky.social/xrpc/app.bsky.actor.getProfile';
|
|
173
|
-
const accessToken = 'test_access_token_12345';
|
|
174
|
-
const nonce = 'test-nonce-67890';
|
|
175
|
-
const proof = await createDPoPProofForResource(keyPair, 'GET', resourceUrl, accessToken, nonce);
|
|
176
|
-
const parts = proof.split('.');
|
|
177
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
178
|
-
expect(decoded.nonce).toBe(nonce);
|
|
179
|
-
});
|
|
180
|
-
it('should sign proof with correct private key', async () => {
|
|
181
|
-
const keyPair = await generateDPoPKeyPair();
|
|
182
|
-
const resourceUrl = 'https://bsky.social/xrpc/app.bsky.actor.getProfile';
|
|
183
|
-
const accessToken = 'test_access_token_12345';
|
|
184
|
-
const proof = await createDPoPProofForResource(keyPair, 'GET', resourceUrl, accessToken);
|
|
185
|
-
// Verify signature can be validated by checking JWT structure
|
|
186
|
-
// Note: We can't verify with the same key pair because the public key
|
|
187
|
-
// from generateDPoPKeyPair only has 'sign' usage, not 'verify'
|
|
188
|
-
// In production, the server would verify using the public key from the JWT header
|
|
189
|
-
const parts = proof.split('.');
|
|
190
|
-
expect(parts.length).toBe(3); // header.payload.signature
|
|
191
|
-
expect(proof).toBeDefined();
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
describe('extractDPoPNonce', () => {
|
|
195
|
-
it('should extract nonce from Headers object', () => {
|
|
196
|
-
const headers = new Headers();
|
|
197
|
-
headers.set('dpop-nonce', 'test-nonce-12345');
|
|
198
|
-
const nonce = extractDPoPNonce(headers);
|
|
199
|
-
expect(nonce).toBe('test-nonce-12345');
|
|
200
|
-
});
|
|
201
|
-
it('should extract nonce from record with lowercase key', () => {
|
|
202
|
-
const headers = { 'dpop-nonce': 'test-nonce-12345' };
|
|
203
|
-
const nonce = extractDPoPNonce(headers);
|
|
204
|
-
expect(nonce).toBe('test-nonce-12345');
|
|
205
|
-
});
|
|
206
|
-
it('should extract nonce from record with uppercase key', () => {
|
|
207
|
-
const headers = { 'DPoP-Nonce': 'test-nonce-12345' };
|
|
208
|
-
const nonce = extractDPoPNonce(headers);
|
|
209
|
-
expect(nonce).toBe('test-nonce-12345');
|
|
210
|
-
});
|
|
211
|
-
it('should return null when nonce is not present', () => {
|
|
212
|
-
const headers = { 'content-type': 'application/json' };
|
|
213
|
-
const nonce = extractDPoPNonce(headers);
|
|
214
|
-
expect(nonce).toBeNull();
|
|
215
|
-
});
|
|
216
|
-
it('should handle array values in headers', () => {
|
|
217
|
-
const headers = { 'dpop-nonce': ['test-nonce-12345'] };
|
|
218
|
-
const nonce = extractDPoPNonce(headers);
|
|
219
|
-
expect(nonce).toBe('test-nonce-12345');
|
|
220
|
-
});
|
|
221
|
-
it('should handle undefined headers', () => {
|
|
222
|
-
const headers = {};
|
|
223
|
-
const nonce = extractDPoPNonce(headers);
|
|
224
|
-
expect(nonce).toBeNull();
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
describe('Nonce Retry Flow', () => {
|
|
228
|
-
it('should handle use_dpop_nonce error response', () => {
|
|
229
|
-
const errorResponse = createMockDPoPNonceError('new-nonce-12345', 401);
|
|
230
|
-
expect(errorResponse.statusCode).toBe(401);
|
|
231
|
-
expect(errorResponse.headers['dpop-nonce']).toBe('new-nonce-12345');
|
|
232
|
-
expect(errorResponse.body).toContain('use_dpop_nonce');
|
|
233
|
-
const nonce = extractDPoPNonce(errorResponse.headers);
|
|
234
|
-
expect(nonce).toBe('new-nonce-12345');
|
|
235
|
-
});
|
|
236
|
-
it('should create proof with nonce after receiving error', async () => {
|
|
237
|
-
const keyPair = await generateDPoPKeyPair();
|
|
238
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
239
|
-
const nonce = 'new-nonce-from-error';
|
|
240
|
-
// First attempt without nonce (would fail)
|
|
241
|
-
// Second attempt with nonce
|
|
242
|
-
const proof = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint, nonce);
|
|
243
|
-
const parts = proof.split('.');
|
|
244
|
-
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
245
|
-
expect(decoded.nonce).toBe(nonce);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
describe('Nonce Rotation and Persistence', () => {
|
|
249
|
-
it('should handle nonce changes during session', async () => {
|
|
250
|
-
const keyPair = await generateDPoPKeyPair();
|
|
251
|
-
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
252
|
-
// Initial nonce
|
|
253
|
-
const nonce1 = 'nonce-1';
|
|
254
|
-
const proof1 = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint, nonce1);
|
|
255
|
-
// Updated nonce
|
|
256
|
-
const nonce2 = 'nonce-2';
|
|
257
|
-
const proof2 = await createDPoPProofForToken(keyPair, 'POST', tokenEndpoint, nonce2);
|
|
258
|
-
const parts1 = proof1.split('.');
|
|
259
|
-
const parts2 = proof2.split('.');
|
|
260
|
-
const decoded1 = JSON.parse(Buffer.from(parts1[1], 'base64url').toString());
|
|
261
|
-
const decoded2 = JSON.parse(Buffer.from(parts2[1], 'base64url').toString());
|
|
262
|
-
expect(decoded1.nonce).toBe(nonce1);
|
|
263
|
-
expect(decoded2.nonce).toBe(nonce2);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
});
|
package/lib/index.test.js
DELETED
|
@@ -1,329 +0,0 @@
|
|
|
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.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
};
|