@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.
@@ -1,50 +0,0 @@
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
- }
@@ -1,234 +0,0 @@
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
- });
package/lib/constant.js DELETED
@@ -1,77 +0,0 @@
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.js DELETED
@@ -1,138 +0,0 @@
1
- import { SignJWT } from 'jose';
2
- import { randomBytes } from 'crypto';
3
- import { createHash } from 'crypto';
4
- import { DPOP_CONFIG } from './constant.js';
5
- /**
6
- * Generate ES256 DPoP key pair
7
- * Uses Web Crypto API (Node.js crypto.webcrypto)
8
- */
9
- export async function generateDPoPKeyPair() {
10
- const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
11
- const keyPair = await crypto.subtle.generateKey({
12
- name: 'ECDSA',
13
- namedCurve: 'P-256', // ES256 uses P-256
14
- }, true, // extractable (needed for server-side storage)
15
- DPOP_CONFIG.keyUsages);
16
- return {
17
- publicKey: keyPair.publicKey,
18
- privateKey: keyPair.privateKey,
19
- };
20
- }
21
- /**
22
- * Export public key as JWK for inclusion in DPoP proof header
23
- */
24
- export async function exportPublicKeyAsJWK(publicKey) {
25
- const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
26
- const jwk = await crypto.subtle.exportKey('jwk', publicKey);
27
- // Remove private key fields if present
28
- const { d, ...publicJwk } = jwk;
29
- return publicJwk;
30
- }
31
- /**
32
- * Create DPoP proof JWT for token endpoint requests
33
- */
34
- export async function createDPoPProofForToken(keyPair, httpMethod, tokenEndpointUrl, nonce) {
35
- const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
36
- const publicKeyJwk = await exportPublicKeyAsJWK(keyPair.publicKey);
37
- // Generate unique jti
38
- const jti = base64UrlEncode(randomBytes(DPOP_CONFIG.jtiLength));
39
- const now = Math.floor(Date.now() / 1000);
40
- const jwt = new SignJWT({
41
- jti,
42
- htm: httpMethod,
43
- htu: tokenEndpointUrl,
44
- iat: now,
45
- exp: now + 60,
46
- ...(nonce && { nonce }),
47
- })
48
- .setProtectedHeader({
49
- typ: 'dpop+jwt',
50
- alg: DPOP_CONFIG.algorithm,
51
- jwk: publicKeyJwk,
52
- });
53
- // Sign with private key
54
- // Note: jose library handles the signing, but we need to convert CryptoKey to JWK format
55
- // For now, we'll use a workaround - in production, you'd use a proper JWT library
56
- // that supports CryptoKey directly
57
- // This is a simplified version - in production, use a library that supports CryptoKey
58
- // For now, we'll need to export the private key and use it with jose
59
- const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
60
- // Ensure kty is defined and cast to JWK
61
- if (!privateKeyJwk.kty || typeof privateKeyJwk.kty !== 'string') {
62
- throw new Error('Private key JWK missing kty field');
63
- }
64
- // Import as JWK for jose - create a properly typed JWK object
65
- const { importJWK } = await import('jose');
66
- const jwkWithKty = {
67
- ...privateKeyJwk,
68
- kty: privateKeyJwk.kty,
69
- };
70
- const signingKey = await importJWK(jwkWithKty, DPOP_CONFIG.algorithm);
71
- return await jwt.sign(signingKey);
72
- }
73
- /**
74
- * Create DPoP proof JWT for resource server (PDS) requests
75
- * Includes ath (access token hash) field
76
- */
77
- export async function createDPoPProofForResource(keyPair, httpMethod, resourceUrl, accessToken, nonce) {
78
- const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
79
- const publicKeyJwk = await exportPublicKeyAsJWK(keyPair.publicKey);
80
- // Hash access token for ath field (same as S256 PKCE challenge)
81
- const tokenHash = createHash('sha256').update(accessToken).digest();
82
- const ath = base64UrlEncode(tokenHash);
83
- const jti = base64UrlEncode(randomBytes(DPOP_CONFIG.jtiLength));
84
- const now = Math.floor(Date.now() / 1000);
85
- const jwt = new SignJWT({
86
- jti,
87
- htm: httpMethod,
88
- htu: resourceUrl,
89
- iat: now,
90
- exp: now + 60,
91
- ath,
92
- ...(nonce && { nonce }),
93
- })
94
- .setProtectedHeader({
95
- typ: 'dpop+jwt',
96
- alg: DPOP_CONFIG.algorithm,
97
- jwk: publicKeyJwk,
98
- });
99
- const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
100
- // Ensure kty is defined and cast to JWK
101
- if (!privateKeyJwk.kty || typeof privateKeyJwk.kty !== 'string') {
102
- throw new Error('Private key JWK missing kty field');
103
- }
104
- // Import as JWK for jose - create a properly typed JWK object
105
- const { importJWK } = await import('jose');
106
- const jwkWithKty = {
107
- ...privateKeyJwk,
108
- kty: privateKeyJwk.kty,
109
- };
110
- const signingKey = await importJWK(jwkWithKty, DPOP_CONFIG.algorithm);
111
- return await jwt.sign(signingKey);
112
- }
113
- /**
114
- * Extract DPoP nonce from response headers
115
- */
116
- export function extractDPoPNonce(headers) {
117
- if (headers instanceof Headers) {
118
- return headers.get('dpop-nonce') || null;
119
- }
120
- const dpopNonce = headers['dpop-nonce'] || headers['DPoP-Nonce'];
121
- if (typeof dpopNonce === 'string') {
122
- return dpopNonce;
123
- }
124
- if (Array.isArray(dpopNonce) && dpopNonce.length > 0 && typeof dpopNonce[0] === 'string') {
125
- return dpopNonce[0];
126
- }
127
- return null;
128
- }
129
- /**
130
- * Base64url encode
131
- */
132
- function base64UrlEncode(buffer) {
133
- return buffer
134
- .toString('base64')
135
- .replace(/\+/g, '-')
136
- .replace(/\//g, '_')
137
- .replace(/=/g, '');
138
- }