@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/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
- };