@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real Account Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* These tests require:
|
|
5
|
+
* 1. A real Bluesky test account
|
|
6
|
+
* 2. Publicly accessible client metadata and JWKS
|
|
7
|
+
* 3. Environment variables configured (see INTEGRATION_TESTING.md)
|
|
8
|
+
*
|
|
9
|
+
* These tests are skipped if TEST_BLUESKY_HANDLE is not set.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeAll } from '@jest/globals';
|
|
12
|
+
import { getTestAccount, getTestConfig, verifyAccessToken, shouldRunIntegrationTests, getTestRedirectUri, } from './real-account-helpers.js';
|
|
13
|
+
import { resolvePDS, fetchAuthorizationServerMetadata } from '../../pds-discovery.js';
|
|
14
|
+
import createBlueskyConnector from '../../index.js';
|
|
15
|
+
describe('Real Account Integration Tests', () => {
|
|
16
|
+
const shouldSkip = !shouldRunIntegrationTests();
|
|
17
|
+
let testAccount;
|
|
18
|
+
let testConfig;
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
if (shouldSkip) {
|
|
21
|
+
console.warn('\n⚠️ Skipping real account tests - TEST_BLUESKY_HANDLE not set\n' +
|
|
22
|
+
' See INTEGRATION_TESTING.md for setup instructions\n');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
testAccount = getTestAccount();
|
|
26
|
+
testConfig = getTestConfig();
|
|
27
|
+
});
|
|
28
|
+
describe('PDS Resolution', () => {
|
|
29
|
+
it('should resolve real handle to DID', async () => {
|
|
30
|
+
if (shouldSkip)
|
|
31
|
+
return;
|
|
32
|
+
const pdsUrl = await resolvePDS(testAccount.handle);
|
|
33
|
+
expect(pdsUrl).toBeDefined();
|
|
34
|
+
expect(pdsUrl).toMatch(/^https:\/\//);
|
|
35
|
+
expect(pdsUrl).toBe(testAccount.pdsUrl);
|
|
36
|
+
}, 30000); // Longer timeout for real network requests
|
|
37
|
+
it('should resolve real DID to PDS', async () => {
|
|
38
|
+
if (shouldSkip)
|
|
39
|
+
return;
|
|
40
|
+
const pdsUrl = await resolvePDS(testAccount.did);
|
|
41
|
+
expect(pdsUrl).toBeDefined();
|
|
42
|
+
expect(pdsUrl).toMatch(/^https:\/\//);
|
|
43
|
+
expect(pdsUrl).toBe(testAccount.pdsUrl);
|
|
44
|
+
}, 30000);
|
|
45
|
+
it('should fetch real authorization server metadata', async () => {
|
|
46
|
+
if (shouldSkip)
|
|
47
|
+
return;
|
|
48
|
+
const metadata = await fetchAuthorizationServerMetadata(testAccount.pdsUrl);
|
|
49
|
+
expect(metadata.issuer).toBeDefined();
|
|
50
|
+
expect(metadata.authorization_endpoint).toBeDefined();
|
|
51
|
+
expect(metadata.token_endpoint).toBeDefined();
|
|
52
|
+
expect(metadata.pushed_authorization_request_endpoint).toBeDefined();
|
|
53
|
+
expect(metadata.scopes_supported).toContain('atproto');
|
|
54
|
+
}, 30000);
|
|
55
|
+
});
|
|
56
|
+
describe('OAuth Flow (Manual Authorization Required)', () => {
|
|
57
|
+
it('should generate valid authorization URL', async () => {
|
|
58
|
+
if (shouldSkip)
|
|
59
|
+
return;
|
|
60
|
+
const mockConfig = jest
|
|
61
|
+
.fn()
|
|
62
|
+
.mockResolvedValue(testConfig);
|
|
63
|
+
const setSession = jest.fn();
|
|
64
|
+
const connector = await createBlueskyConnector({ getConfig: mockConfig });
|
|
65
|
+
const authUrl = await connector.getAuthorizationUri({
|
|
66
|
+
state: `test-state-${Date.now()}`,
|
|
67
|
+
redirectUri: getTestRedirectUri(),
|
|
68
|
+
connectorId: 'bluesky-web',
|
|
69
|
+
connectorFactoryId: 'bluesky',
|
|
70
|
+
jti: `test-jti-${Date.now()}`,
|
|
71
|
+
headers: {},
|
|
72
|
+
}, setSession);
|
|
73
|
+
expect(authUrl).toBeDefined();
|
|
74
|
+
expect(authUrl).toContain('bsky.social');
|
|
75
|
+
expect(authUrl).toContain('request_uri');
|
|
76
|
+
// Log URL for manual testing
|
|
77
|
+
console.log('\n📋 Authorization URL for manual testing:');
|
|
78
|
+
console.log(authUrl);
|
|
79
|
+
console.log('\n⚠️ To complete the test:');
|
|
80
|
+
console.log(' 1. Open the URL above in a browser');
|
|
81
|
+
console.log(' 2. Complete the OAuth authorization');
|
|
82
|
+
console.log(' 3. Copy the authorization code from the callback URL');
|
|
83
|
+
console.log(' 4. Set TEST_AUTHORIZATION_CODE environment variable');
|
|
84
|
+
console.log(' 5. Re-run this test to verify token exchange\n');
|
|
85
|
+
}, 60000);
|
|
86
|
+
});
|
|
87
|
+
describe('Token Verification', () => {
|
|
88
|
+
it('should verify a real access token', async () => {
|
|
89
|
+
if (shouldSkip)
|
|
90
|
+
return;
|
|
91
|
+
// This test requires a pre-obtained access token
|
|
92
|
+
const accessToken = process.env.TEST_ACCESS_TOKEN;
|
|
93
|
+
if (!accessToken) {
|
|
94
|
+
console.warn('\n⚠️ Skipping token verification - TEST_ACCESS_TOKEN not set\n' +
|
|
95
|
+
' To test token verification:\n' +
|
|
96
|
+
' 1. Complete OAuth flow manually\n' +
|
|
97
|
+
' 2. Extract access_token from session\n' +
|
|
98
|
+
' 3. Set TEST_ACCESS_TOKEN environment variable\n' +
|
|
99
|
+
' 4. Re-run this test\n');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const isValid = await verifyAccessToken(accessToken, testAccount.did, testAccount.pdsUrl);
|
|
103
|
+
expect(isValid).toBe(true);
|
|
104
|
+
}, 30000);
|
|
105
|
+
});
|
|
106
|
+
describe('Error Scenarios', () => {
|
|
107
|
+
it('should handle invalid handle gracefully', async () => {
|
|
108
|
+
if (shouldSkip)
|
|
109
|
+
return;
|
|
110
|
+
await expect(resolvePDS('invalid-handle-12345.bsky.social')).rejects.toThrow();
|
|
111
|
+
}, 30000);
|
|
112
|
+
it('should handle invalid DID gracefully', async () => {
|
|
113
|
+
if (shouldSkip)
|
|
114
|
+
return;
|
|
115
|
+
await expect(resolvePDS('did:plc:invalid123456789')).rejects.toThrow();
|
|
116
|
+
}, 30000);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Assertion Generation for private_key_jwt
|
|
3
|
+
*
|
|
4
|
+
* Generates JWT client assertion for OAuth token endpoint authentication
|
|
5
|
+
* RFC 7523: https://datatracker.ietf.org/doc/html/rfc7523
|
|
6
|
+
*/
|
|
7
|
+
import type { BlueskyConfig } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Generate client assertion JWT
|
|
10
|
+
*
|
|
11
|
+
* Note: This requires access to the private key, which should be:
|
|
12
|
+
* 1. Stored in Logto connector config (encrypted by Logto), OR
|
|
13
|
+
* 2. Retrieved from a secure server endpoint
|
|
14
|
+
*
|
|
15
|
+
* For now, we'll support both approaches - if privateKeyJwk is in config, use it.
|
|
16
|
+
* Otherwise, the server should provide an endpoint to generate assertions.
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateClientAssertion(clientId: string, tokenEndpoint: string, config: BlueskyConfig): Promise<string | null>;
|
|
19
|
+
//# sourceMappingURL=client-assertion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-assertion.d.ts","sourceRoot":"","sources":["../src/client-assertion.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAmCxB"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Assertion Generation for private_key_jwt
|
|
3
|
+
*
|
|
4
|
+
* Generates JWT client assertion for OAuth token endpoint authentication
|
|
5
|
+
* RFC 7523: https://datatracker.ietf.org/doc/html/rfc7523
|
|
6
|
+
*/
|
|
7
|
+
import { SignJWT, importJWK } from 'jose';
|
|
8
|
+
import { randomBytes } from 'crypto';
|
|
9
|
+
/**
|
|
10
|
+
* Generate client assertion JWT
|
|
11
|
+
*
|
|
12
|
+
* Note: This requires access to the private key, which should be:
|
|
13
|
+
* 1. Stored in Logto connector config (encrypted by Logto), OR
|
|
14
|
+
* 2. Retrieved from a secure server endpoint
|
|
15
|
+
*
|
|
16
|
+
* For now, we'll support both approaches - if privateKeyJwk is in config, use it.
|
|
17
|
+
* Otherwise, the server should provide an endpoint to generate assertions.
|
|
18
|
+
*/
|
|
19
|
+
export async function generateClientAssertion(clientId, tokenEndpoint, config) {
|
|
20
|
+
// Check if private key is available in config (for server-side connectors)
|
|
21
|
+
// In production, this would come from Vault via a server endpoint
|
|
22
|
+
const privateKeyJwkStr = config.privateKeyJwk;
|
|
23
|
+
if (!privateKeyJwkStr) {
|
|
24
|
+
// Private key not in config - would need to call server endpoint
|
|
25
|
+
// For now, return null to indicate assertion generation failed
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const privateKeyJwk = JSON.parse(privateKeyJwkStr);
|
|
30
|
+
const privateKey = await importJWK(privateKeyJwk, 'ES256');
|
|
31
|
+
const now = Math.floor(Date.now() / 1000);
|
|
32
|
+
const jti = randomBytes(16).toString('base64url');
|
|
33
|
+
const jwt = new SignJWT({
|
|
34
|
+
iss: clientId,
|
|
35
|
+
sub: clientId,
|
|
36
|
+
aud: tokenEndpoint,
|
|
37
|
+
jti,
|
|
38
|
+
exp: now + 600,
|
|
39
|
+
iat: now,
|
|
40
|
+
})
|
|
41
|
+
.setProtectedHeader({
|
|
42
|
+
alg: 'ES256',
|
|
43
|
+
typ: 'JWT',
|
|
44
|
+
});
|
|
45
|
+
return await jwt.sign(privateKey);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`Failed to generate client assertion: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-assertion.test.d.ts","sourceRoot":"","sources":["../src/client-assertion.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Assertion Tests
|
|
3
|
+
* Tests for JWT client assertion generation (RFC 7523)
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from '@jest/globals';
|
|
6
|
+
import { jwtVerify } from 'jose';
|
|
7
|
+
import { generateClientAssertion } from './client-assertion.js';
|
|
8
|
+
import { generateTestKeyPair, exportPublicKeyAsJWK } from './test-utils.js';
|
|
9
|
+
describe('Client Assertion Generation', () => {
|
|
10
|
+
describe('generateClientAssertion', () => {
|
|
11
|
+
it('should generate valid JWT client assertion', async () => {
|
|
12
|
+
const keyPair = await generateTestKeyPair();
|
|
13
|
+
const privateKeyJwk = await exportPublicKeyAsJWK(keyPair.privateKey);
|
|
14
|
+
// Add private key fields for signing
|
|
15
|
+
const crypto = globalThis.crypto;
|
|
16
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
17
|
+
const config = {
|
|
18
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
19
|
+
clientId: 'https://app.example.com/client-id',
|
|
20
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
21
|
+
scope: 'atproto transition:generic',
|
|
22
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
23
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
24
|
+
};
|
|
25
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
26
|
+
expect(assertion).toBeDefined();
|
|
27
|
+
expect(typeof assertion).toBe('string');
|
|
28
|
+
});
|
|
29
|
+
it('should include required JWT header fields', async () => {
|
|
30
|
+
const keyPair = await generateTestKeyPair();
|
|
31
|
+
const crypto = globalThis.crypto;
|
|
32
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
33
|
+
const config = {
|
|
34
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
35
|
+
clientId: 'https://app.example.com/client-id',
|
|
36
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
37
|
+
scope: 'atproto transition:generic',
|
|
38
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
39
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
40
|
+
};
|
|
41
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
42
|
+
expect(assertion).not.toBeNull();
|
|
43
|
+
if (assertion) {
|
|
44
|
+
const parts = assertion.split('.');
|
|
45
|
+
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
|
46
|
+
expect(header.alg).toBe('ES256');
|
|
47
|
+
expect(header.typ).toBe('JWT');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('should include required JWT payload fields', async () => {
|
|
51
|
+
const keyPair = await generateTestKeyPair();
|
|
52
|
+
const crypto = globalThis.crypto;
|
|
53
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
54
|
+
const clientId = 'https://app.example.com/client-id';
|
|
55
|
+
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
56
|
+
const config = {
|
|
57
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
58
|
+
clientId,
|
|
59
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
60
|
+
scope: 'atproto transition:generic',
|
|
61
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
62
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
63
|
+
};
|
|
64
|
+
const assertion = await generateClientAssertion(clientId, tokenEndpoint, config);
|
|
65
|
+
expect(assertion).not.toBeNull();
|
|
66
|
+
if (assertion) {
|
|
67
|
+
const parts = assertion.split('.');
|
|
68
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
69
|
+
expect(decoded.iss).toBe(clientId);
|
|
70
|
+
expect(decoded.sub).toBe(clientId);
|
|
71
|
+
expect(decoded.aud).toBe(tokenEndpoint);
|
|
72
|
+
expect(decoded.jti).toBeDefined();
|
|
73
|
+
expect(decoded.iat).toBeDefined();
|
|
74
|
+
expect(decoded.exp).toBeDefined();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('should sign assertion with ES256 algorithm', async () => {
|
|
78
|
+
const keyPair = await generateTestKeyPair();
|
|
79
|
+
const crypto = globalThis.crypto;
|
|
80
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
81
|
+
const config = {
|
|
82
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
83
|
+
clientId: 'https://app.example.com/client-id',
|
|
84
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
85
|
+
scope: 'atproto transition:generic',
|
|
86
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
87
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
88
|
+
};
|
|
89
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
90
|
+
expect(assertion).not.toBeNull();
|
|
91
|
+
if (assertion) {
|
|
92
|
+
const parts = assertion.split('.');
|
|
93
|
+
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
|
94
|
+
expect(header.alg).toBe('ES256');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it('should include correct client_id in iss and sub', async () => {
|
|
98
|
+
const keyPair = await generateTestKeyPair();
|
|
99
|
+
const crypto = globalThis.crypto;
|
|
100
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
101
|
+
const clientId = 'https://app.example.com/client-id';
|
|
102
|
+
const config = {
|
|
103
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
104
|
+
clientId,
|
|
105
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
106
|
+
scope: 'atproto transition:generic',
|
|
107
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
108
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
109
|
+
};
|
|
110
|
+
const assertion = await generateClientAssertion(clientId, 'https://bsky.social/oauth/token', config);
|
|
111
|
+
expect(assertion).not.toBeNull();
|
|
112
|
+
if (assertion) {
|
|
113
|
+
const parts = assertion.split('.');
|
|
114
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
115
|
+
expect(decoded.iss).toBe(clientId);
|
|
116
|
+
expect(decoded.sub).toBe(clientId);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
it('should include token endpoint as audience', async () => {
|
|
120
|
+
const keyPair = await generateTestKeyPair();
|
|
121
|
+
const crypto = globalThis.crypto;
|
|
122
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
123
|
+
const tokenEndpoint = 'https://bsky.social/oauth/token';
|
|
124
|
+
const config = {
|
|
125
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
126
|
+
clientId: 'https://app.example.com/client-id',
|
|
127
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
128
|
+
scope: 'atproto transition:generic',
|
|
129
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
130
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
131
|
+
};
|
|
132
|
+
const assertion = await generateClientAssertion(config.clientId, tokenEndpoint, config);
|
|
133
|
+
expect(assertion).not.toBeNull();
|
|
134
|
+
if (assertion) {
|
|
135
|
+
const parts = assertion.split('.');
|
|
136
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
137
|
+
expect(decoded.aud).toBe(tokenEndpoint);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
it('should generate unique jti for each assertion', async () => {
|
|
141
|
+
const keyPair = await generateTestKeyPair();
|
|
142
|
+
const crypto = globalThis.crypto;
|
|
143
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
144
|
+
const config = {
|
|
145
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
146
|
+
clientId: 'https://app.example.com/client-id',
|
|
147
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
148
|
+
scope: 'atproto transition:generic',
|
|
149
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
150
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
151
|
+
};
|
|
152
|
+
const assertion1 = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
153
|
+
const assertion2 = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
154
|
+
expect(assertion1).not.toBeNull();
|
|
155
|
+
expect(assertion2).not.toBeNull();
|
|
156
|
+
if (assertion1 && assertion2) {
|
|
157
|
+
const parts1 = assertion1.split('.');
|
|
158
|
+
const parts2 = assertion2.split('.');
|
|
159
|
+
const decoded1 = JSON.parse(Buffer.from(parts1[1], 'base64url').toString());
|
|
160
|
+
const decoded2 = JSON.parse(Buffer.from(parts2[1], 'base64url').toString());
|
|
161
|
+
expect(decoded1.jti).not.toBe(decoded2.jti);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
it('should set appropriate expiration time', async () => {
|
|
165
|
+
const keyPair = await generateTestKeyPair();
|
|
166
|
+
const crypto = globalThis.crypto;
|
|
167
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
168
|
+
const config = {
|
|
169
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
170
|
+
clientId: 'https://app.example.com/client-id',
|
|
171
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
172
|
+
scope: 'atproto transition:generic',
|
|
173
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
174
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
175
|
+
};
|
|
176
|
+
const now = Math.floor(Date.now() / 1000);
|
|
177
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
178
|
+
expect(assertion).not.toBeNull();
|
|
179
|
+
if (assertion) {
|
|
180
|
+
const parts = assertion.split('.');
|
|
181
|
+
const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
182
|
+
const iat = decoded.iat;
|
|
183
|
+
const exp = decoded.exp;
|
|
184
|
+
// Should expire 10 minutes (600 seconds) after issued
|
|
185
|
+
expect(exp - iat).toBe(600);
|
|
186
|
+
expect(exp).toBeGreaterThan(now);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it('should handle missing private key gracefully', async () => {
|
|
190
|
+
const config = {
|
|
191
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
192
|
+
clientId: 'https://app.example.com/client-id',
|
|
193
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
194
|
+
scope: 'atproto transition:generic',
|
|
195
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
196
|
+
};
|
|
197
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
198
|
+
expect(assertion).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
it('should validate assertion can be verified with public key', async () => {
|
|
201
|
+
const keyPair = await generateTestKeyPair();
|
|
202
|
+
const crypto = globalThis.crypto;
|
|
203
|
+
const fullPrivateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
204
|
+
const config = {
|
|
205
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
206
|
+
clientId: 'https://app.example.com/client-id',
|
|
207
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
208
|
+
scope: 'atproto transition:generic',
|
|
209
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
210
|
+
privateKeyJwk: JSON.stringify(fullPrivateKeyJwk),
|
|
211
|
+
};
|
|
212
|
+
const assertion = await generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config);
|
|
213
|
+
expect(assertion).not.toBeNull();
|
|
214
|
+
if (assertion) {
|
|
215
|
+
// Verify signature with public key
|
|
216
|
+
const { payload } = await jwtVerify(assertion, keyPair.publicKey);
|
|
217
|
+
expect(payload).toBeDefined();
|
|
218
|
+
expect(payload.iss).toBe(config.clientId);
|
|
219
|
+
expect(payload.sub).toBe(config.clientId);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
it('should throw error on invalid private key', async () => {
|
|
223
|
+
const config = {
|
|
224
|
+
clientMetadataUri: 'https://app.example.com/oauth/client-metadata.json',
|
|
225
|
+
clientId: 'https://app.example.com/client-id',
|
|
226
|
+
jwksUri: 'https://app.example.com/.well-known/jwks.json',
|
|
227
|
+
scope: 'atproto transition:generic',
|
|
228
|
+
tokenEndpointAuthMethod: 'private_key_jwt',
|
|
229
|
+
privateKeyJwk: JSON.stringify({ invalid: 'key' }),
|
|
230
|
+
};
|
|
231
|
+
await expect(generateClientAssertion(config.clientId, 'https://bsky.social/oauth/token', config)).rejects.toThrow();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ConnectorMetadata } from '@logto/connector-kit';
|
|
2
|
+
export declare const defaultAuthorizationEndpoint = "https://bsky.social/oauth/authorize";
|
|
3
|
+
export declare const defaultMetadata: ConnectorMetadata;
|
|
4
|
+
export declare const WELL_KNOWN_PATHS: {
|
|
5
|
+
readonly OAUTH_CLIENT_METADATA: "/.well-known/oauth-client-metadata.json";
|
|
6
|
+
readonly OAUTH_AUTHORIZATION_SERVER: "/.well-known/oauth-authorization-server";
|
|
7
|
+
readonly OAUTH_PROTECTED_RESOURCE: "/.well-known/oauth-protected-resource";
|
|
8
|
+
readonly ATPROTO_DID: "/.well-known/atproto-did";
|
|
9
|
+
};
|
|
10
|
+
export declare const HTTP_CLIENT_CONFIG: {
|
|
11
|
+
readonly timeout: 10000;
|
|
12
|
+
readonly maxRedirects: 3;
|
|
13
|
+
readonly maxResponseSize: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const PKCE_CONFIG: {
|
|
16
|
+
readonly codeVerifierLength: 43;
|
|
17
|
+
readonly codeChallengeMethod: "S256";
|
|
18
|
+
};
|
|
19
|
+
export declare const DPOP_CONFIG: {
|
|
20
|
+
readonly algorithm: "ES256";
|
|
21
|
+
readonly keyUsages: readonly ["sign"];
|
|
22
|
+
readonly jtiLength: 16;
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=constant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constant.d.ts","sourceRoot":"","sources":["../src/constant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAO9D,eAAO,MAAM,4BAA4B,wCAAwC,CAAC;AAGlF,eAAO,MAAM,eAAe,EAAE,iBAgD7B,CAAC;AAGF,eAAO,MAAM,gBAAgB;;;;;CAKnB,CAAC;AAGX,eAAO,MAAM,kBAAkB;;;;CAIrB,CAAC;AAGX,eAAO,MAAM,WAAW;;;CAGd,CAAC;AAGX,eAAO,MAAM,WAAW;;;;CAId,CAAC"}
|
package/lib/constant.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ConnectorPlatform, ConnectorConfigFormItemType, } from '@logto/connector-kit';
|
|
2
|
+
// AT Protocol OAuth endpoints (will be discovered from PDS metadata)
|
|
3
|
+
export const defaultAuthorizationEndpoint = 'https://bsky.social/oauth/authorize';
|
|
4
|
+
// Connector Metadata
|
|
5
|
+
export const defaultMetadata = {
|
|
6
|
+
id: 'bluesky-web',
|
|
7
|
+
target: 'bluesky',
|
|
8
|
+
platform: ConnectorPlatform.Web,
|
|
9
|
+
name: {
|
|
10
|
+
en: 'Bluesky',
|
|
11
|
+
},
|
|
12
|
+
logo: './logo.svg',
|
|
13
|
+
logoDark: './logo-dark.svg',
|
|
14
|
+
description: {
|
|
15
|
+
en: 'Sign in with Bluesky via AT Protocol OAuth. Supports custom PDS instances with PAR, PKCE, and DPoP.',
|
|
16
|
+
},
|
|
17
|
+
readme: './README.md',
|
|
18
|
+
formItems: [
|
|
19
|
+
{
|
|
20
|
+
key: 'clientMetadataUri',
|
|
21
|
+
type: ConnectorConfigFormItemType.Text,
|
|
22
|
+
required: true,
|
|
23
|
+
label: 'Client Metadata URI',
|
|
24
|
+
placeholder: 'https://yourdomain.com/.well-known/oauth-client-metadata.json',
|
|
25
|
+
description: 'Publicly accessible URL to your OAuth client metadata',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'clientId',
|
|
29
|
+
type: ConnectorConfigFormItemType.Text,
|
|
30
|
+
required: true,
|
|
31
|
+
label: 'Client ID',
|
|
32
|
+
placeholder: 'https://yourdomain.com/client-id',
|
|
33
|
+
description: 'OAuth client identifier (usually same as metadata URI)',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: 'jwksUri',
|
|
37
|
+
type: ConnectorConfigFormItemType.Text,
|
|
38
|
+
required: true,
|
|
39
|
+
label: 'JWKS URI',
|
|
40
|
+
placeholder: 'https://yourdomain.com/.well-known/jwks.json',
|
|
41
|
+
description: 'Public key set for client authentication (required for confidential clients with private_key_jwt)',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'scope',
|
|
45
|
+
type: ConnectorConfigFormItemType.Text,
|
|
46
|
+
required: false,
|
|
47
|
+
label: 'OAuth Scopes',
|
|
48
|
+
placeholder: 'atproto transition:generic',
|
|
49
|
+
defaultValue: 'atproto transition:generic',
|
|
50
|
+
description: 'Space-separated OAuth scopes',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
// Well-known paths for AT Protocol
|
|
55
|
+
export const WELL_KNOWN_PATHS = {
|
|
56
|
+
OAUTH_CLIENT_METADATA: '/.well-known/oauth-client-metadata.json',
|
|
57
|
+
OAUTH_AUTHORIZATION_SERVER: '/.well-known/oauth-authorization-server',
|
|
58
|
+
OAUTH_PROTECTED_RESOURCE: '/.well-known/oauth-protected-resource',
|
|
59
|
+
ATPROTO_DID: '/.well-known/atproto-did',
|
|
60
|
+
};
|
|
61
|
+
// HTTP client settings for SSRF hardening
|
|
62
|
+
export const HTTP_CLIENT_CONFIG = {
|
|
63
|
+
timeout: 10000,
|
|
64
|
+
maxRedirects: 3,
|
|
65
|
+
maxResponseSize: 1024 * 1024, // 1MB
|
|
66
|
+
};
|
|
67
|
+
// PKCE settings
|
|
68
|
+
export const PKCE_CONFIG = {
|
|
69
|
+
codeVerifierLength: 43,
|
|
70
|
+
codeChallengeMethod: 'S256',
|
|
71
|
+
};
|
|
72
|
+
// DPoP settings
|
|
73
|
+
export const DPOP_CONFIG = {
|
|
74
|
+
algorithm: 'ES256',
|
|
75
|
+
keyUsages: ['sign'],
|
|
76
|
+
jtiLength: 16, // Random token length
|
|
77
|
+
};
|
package/lib/dpop.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DPoPKeyPair } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate ES256 DPoP key pair
|
|
4
|
+
* Uses Web Crypto API (Node.js crypto.webcrypto)
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateDPoPKeyPair(): Promise<DPoPKeyPair>;
|
|
7
|
+
/**
|
|
8
|
+
* Export public key as JWK for inclusion in DPoP proof header
|
|
9
|
+
*/
|
|
10
|
+
export declare function exportPublicKeyAsJWK(publicKey: CryptoKey): Promise<import('jose').JWK>;
|
|
11
|
+
/**
|
|
12
|
+
* Create DPoP proof JWT for token endpoint requests
|
|
13
|
+
*/
|
|
14
|
+
export declare function createDPoPProofForToken(keyPair: DPoPKeyPair, httpMethod: string, tokenEndpointUrl: string, nonce?: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Create DPoP proof JWT for resource server (PDS) requests
|
|
17
|
+
* Includes ath (access token hash) field
|
|
18
|
+
*/
|
|
19
|
+
export declare function createDPoPProofForResource(keyPair: DPoPKeyPair, httpMethod: string, resourceUrl: string, accessToken: string, nonce?: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Extract DPoP nonce from response headers
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractDPoPNonce(headers: Headers | Record<string, string | string[] | undefined>): string | null;
|
|
24
|
+
//# sourceMappingURL=dpop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dpop.d.ts","sourceRoot":"","sources":["../src/dpop.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAa,MAAM,YAAY,CAAC;AAGzD;;;GAGG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,WAAW,CAAC,CAgBhE;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,MAAM,EAAE,GAAG,CAAC,CAO5F;AAED;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,WAAW,EACpB,UAAU,EAAE,MAAM,EAClB,gBAAgB,EAAE,MAAM,EACxB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CA8CjB;AAED;;;GAGG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,WAAW,EACpB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CA0CjB;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,GAAG,MAAM,GAAG,IAAI,CAchH"}
|