@openverifiable/connector-bluesky 1.0.0 → 1.0.1

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/index.js CHANGED
@@ -87,7 +87,7 @@ const authorizationServerMetadataGuard = z.object({
87
87
  pushed_authorization_request_endpoint: z.string().url(),
88
88
  authorization_endpoint: z.string().url(),
89
89
  token_endpoint: z.string().url(),
90
- scopes_supported: z.string(),
90
+ scopes_supported: z.union([z.string(), z.array(z.string())]),
91
91
  });
92
92
  /**
93
93
  * ResourceServerMetadata
@@ -596,13 +596,22 @@ async function fetchAuthorizationServerMetadataFromUrl(authServerUrl) {
596
596
  }
597
597
  // Validate that atproto scope is supported
598
598
  const scopesSupported = parsed.data.scopes_supported;
599
+ let scopes;
599
600
  if (typeof scopesSupported === 'string') {
600
- const scopes = scopesSupported.split(' ');
601
- if (!scopes.includes('atproto')) {
602
- throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
603
- error: 'Authorization server does not support atproto scope',
604
- });
605
- }
601
+ // Handle space-separated string format (RFC 8414 format)
602
+ scopes = scopesSupported.split(' ');
603
+ }
604
+ else if (Array.isArray(scopesSupported)) {
605
+ // Handle array format (Bluesky/AT Protocol format)
606
+ scopes = scopesSupported;
607
+ }
608
+ else {
609
+ scopes = [];
610
+ }
611
+ if (!scopes.includes('atproto')) {
612
+ throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, {
613
+ error: 'Authorization server does not support atproto scope',
614
+ });
606
615
  }
607
616
  return parsed.data;
608
617
  }
package/lib/types.d.ts CHANGED
@@ -141,19 +141,19 @@ export declare const authorizationServerMetadataGuard: z.ZodObject<{
141
141
  pushed_authorization_request_endpoint: z.ZodString;
142
142
  authorization_endpoint: z.ZodString;
143
143
  token_endpoint: z.ZodString;
144
- scopes_supported: z.ZodString;
144
+ scopes_supported: z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>;
145
145
  }, "strip", z.ZodTypeAny, {
146
146
  issuer: string;
147
147
  pushed_authorization_request_endpoint: string;
148
148
  authorization_endpoint: string;
149
149
  token_endpoint: string;
150
- scopes_supported: string;
150
+ scopes_supported: string | string[];
151
151
  }, {
152
152
  issuer: string;
153
153
  pushed_authorization_request_endpoint: string;
154
154
  authorization_endpoint: string;
155
155
  token_endpoint: string;
156
- scopes_supported: string;
156
+ scopes_supported: string | string[];
157
157
  }>;
158
158
  export type AuthorizationServerMetadata = z.infer<typeof authorizationServerMetadataGuard>;
159
159
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openverifiable/connector-bluesky",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Bluesky/AT Protocol OAuth connector for LogTo with PAR, PKCE, and DPoP support",
5
5
  "author": "OpenVerifiable (https://github.com/openverifiable)",
6
6
  "homepage": "https://openverifiable.org",
@@ -33,15 +33,14 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "precommit": "lint-staged",
36
- "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json",
37
36
  "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c && exit 0",
38
37
  "format": "prettier --write 'src/**/*.{js,ts,cjs,mjs}'",
39
38
  "lint": "eslint --ext .ts src",
40
39
  "lint:report": "npm lint --format json --output-file report.json",
41
- "test": "npm build:test && NODE_OPTIONS=--experimental-vm-modules jest",
42
- "test:coverage": "npm run test --coverage --silent",
43
- "test:unit": "npm build:test && NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=integration",
44
- "test:integration": "npm build:test && NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=integration"
40
+ "test": "vitest src",
41
+ "test:coverage": "vitest src --coverage",
42
+ "test:unit": "vitest src --exclude '**/integration/**'",
43
+ "test:integration": "vitest src/__tests__/integration"
45
44
  },
46
45
  "dependencies": {
47
46
  "@logto/connector-kit": "4.6.0",
@@ -76,6 +75,8 @@
76
75
  "nock": "^13.5.4",
77
76
  "prettier": "^2.8.8",
78
77
  "rollup": "^3.29.4",
78
+ "vitest": "^3.1.1",
79
+ "@vitest/coverage-v8": "^3.1.1",
79
80
  "rollup-plugin-summary": "^2.0.0",
80
81
  "semantic-release": "^22.0.12",
81
82
  "supertest": "^6.3.4",
@@ -1,56 +0,0 @@
1
- /**
2
- * DID Document Fixtures
3
- * Valid DID documents for testing DID resolution and PDS discovery
4
- */
5
- export const didPlcDocument = {
6
- id: 'did:plc:example123456789',
7
- '@context': [
8
- 'https://www.w3.org/ns/did/v1',
9
- 'https://w3id.org/security/multikey/v1',
10
- ],
11
- service: [
12
- {
13
- id: '#atproto_pds',
14
- type: 'AtprotoPersonalDataServer',
15
- serviceEndpoint: 'https://bsky.social',
16
- },
17
- ],
18
- verificationMethod: [
19
- {
20
- id: '#atproto',
21
- type: 'Multikey',
22
- controller: 'did:plc:example123456789',
23
- publicKeyMultibase: 'z1234567890abcdef',
24
- },
25
- ],
26
- };
27
- export const didWebDocument = {
28
- id: 'did:web:example.com',
29
- '@context': [
30
- 'https://www.w3.org/ns/did/v1',
31
- 'https://w3id.org/security/multikey/v1',
32
- ],
33
- service: [
34
- {
35
- id: '#atproto_pds',
36
- type: 'AtprotoPersonalDataServer',
37
- serviceEndpoint: 'https://pds.example.com',
38
- },
39
- ],
40
- verificationMethod: [
41
- {
42
- id: '#atproto',
43
- type: 'Multikey',
44
- controller: 'did:web:example.com',
45
- publicKeyMultibase: 'zabcdef1234567890',
46
- },
47
- ],
48
- };
49
- export const didDocumentWithHandle = {
50
- ...didPlcDocument,
51
- alsoKnownAs: ['at://example.bsky.social'],
52
- };
53
- export const invalidDidDocument = {
54
- id: 'did:plc:invalid',
55
- // Missing required service field
56
- };
@@ -1,56 +0,0 @@
1
- /**
2
- * OAuth Metadata Fixtures
3
- * Authorization server and resource server metadata for testing
4
- */
5
- export const authorizationServerMetadata = {
6
- issuer: 'https://bsky.social',
7
- pushed_authorization_request_endpoint: 'https://bsky.social/oauth/par',
8
- authorization_endpoint: 'https://bsky.social/oauth/authorize',
9
- token_endpoint: 'https://bsky.social/oauth/token',
10
- scopes_supported: 'atproto transition:generic',
11
- response_types_supported: ['code'],
12
- grant_types_supported: ['authorization_code', 'refresh_token'],
13
- code_challenge_methods_supported: ['S256'],
14
- dpop_signing_alg_values_supported: ['ES256'],
15
- };
16
- export const authorizationServerMetadataWithoutAtproto = {
17
- ...authorizationServerMetadata,
18
- scopes_supported: 'openid profile email',
19
- };
20
- export const resourceServerMetadata = {
21
- resource: 'https://bsky.social',
22
- authorization_servers: ['https://bsky.social'],
23
- };
24
- export const resourceServerMetadataWithEntryway = {
25
- resource: 'https://pds.example.com',
26
- authorization_servers: ['https://entryway.example.com'],
27
- };
28
- export const clientMetadata = {
29
- client_id: 'https://app.example.com/oauth/client-metadata.json',
30
- application_type: 'web',
31
- client_name: 'Example Browser App',
32
- client_uri: 'https://app.example.com',
33
- dpop_bound_access_tokens: true,
34
- grant_types: ['authorization_code', 'refresh_token'],
35
- redirect_uris: ['https://app.example.com/oauth/callback'],
36
- response_types: ['code'],
37
- scope: 'atproto transition:generic',
38
- token_endpoint_auth_method: 'none',
39
- };
40
- export const confidentialClientMetadata = {
41
- ...clientMetadata,
42
- token_endpoint_auth_method: 'private_key_jwt',
43
- token_endpoint_auth_signing_alg: 'ES256',
44
- jwks_uri: 'https://app.example.com/.well-known/jwks.json',
45
- jwks: {
46
- keys: [
47
- {
48
- kty: 'EC',
49
- crv: 'P-256',
50
- x: 'example_x',
51
- y: 'example_y',
52
- kid: 'key-1',
53
- },
54
- ],
55
- },
56
- };
@@ -1,41 +0,0 @@
1
- /**
2
- * OAuth Error Response Fixtures
3
- * Standard OAuth error responses for testing error handling
4
- */
5
- export const useDpopNonceError = {
6
- error: 'use_dpop_nonce',
7
- error_description: 'Resource server requires nonce in DPoP proof',
8
- nonce: 'eyJ7S_zG.eyJH0-Z.HX4w-7v',
9
- };
10
- export const invalidRequestError = {
11
- error: 'invalid_request',
12
- error_description: 'The request is missing a required parameter',
13
- };
14
- export const invalidClientError = {
15
- error: 'invalid_client',
16
- error_description: 'Client authentication failed',
17
- };
18
- export const invalidGrantError = {
19
- error: 'invalid_grant',
20
- error_description: 'The provided authorization grant is invalid',
21
- };
22
- export const invalidScopeError = {
23
- error: 'invalid_scope',
24
- error_description: 'The requested scope is invalid',
25
- };
26
- export const unauthorizedClientError = {
27
- error: 'unauthorized_client',
28
- error_description: 'The client is not authorized to request an authorization code',
29
- };
30
- export const unsupportedGrantTypeError = {
31
- error: 'unsupported_grant_type',
32
- error_description: 'The authorization grant type is not supported',
33
- };
34
- export const serverError = {
35
- error: 'server_error',
36
- error_description: 'The authorization server encountered an error',
37
- };
38
- export const temporarilyUnavailableError = {
39
- error: 'temporarily_unavailable',
40
- error_description: 'The authorization server is temporarily unavailable',
41
- };
@@ -1,28 +0,0 @@
1
- /**
2
- * Profile Response Fixtures
3
- * AT Protocol profile responses from PDS for testing
4
- */
5
- export const profileResponse = {
6
- did: 'did:plc:example123456789',
7
- handle: 'example.bsky.social',
8
- displayName: 'Example User',
9
- avatar: 'https://cdn.bsky.app/avatar/example.jpg',
10
- email: 'user@example.com',
11
- description: 'This is an example user profile',
12
- createdAt: '2024-01-01T00:00:00.000Z',
13
- };
14
- export const profileResponseMinimal = {
15
- did: 'did:plc:example123456789',
16
- handle: 'example.bsky.social',
17
- };
18
- export const profileResponseWithoutEmail = {
19
- did: 'did:plc:example123456789',
20
- handle: 'example.bsky.social',
21
- displayName: 'Example User',
22
- avatar: 'https://cdn.bsky.app/avatar/example.jpg',
23
- description: 'This is an example user profile',
24
- };
25
- export const profileResponseWithDifferentDID = {
26
- ...profileResponse,
27
- did: 'did:plc:different123456',
28
- };
@@ -1,33 +0,0 @@
1
- /**
2
- * Token Response Fixtures
3
- * OAuth token exchange responses for testing
4
- */
5
- export const tokenResponse = {
6
- access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
7
- refresh_token: 'refresh_token_12345',
8
- token_type: 'Bearer',
9
- expires_in: 3600,
10
- scope: 'atproto transition:generic',
11
- sub: 'did:plc:example123456789',
12
- did: 'did:plc:example123456789',
13
- };
14
- export const tokenResponseWithoutRefresh = {
15
- access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
16
- token_type: 'Bearer',
17
- expires_in: 3600,
18
- scope: 'atproto transition:generic',
19
- sub: 'did:plc:example123456789',
20
- };
21
- export const tokenResponseWithDifferentDID = {
22
- ...tokenResponse,
23
- sub: 'did:plc:different123456',
24
- did: 'did:plc:different123456',
25
- };
26
- export const refreshTokenResponse = {
27
- access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_token',
28
- refresh_token: 'new_refresh_token_67890',
29
- token_type: 'Bearer',
30
- expires_in: 3600,
31
- scope: 'atproto transition:generic',
32
- sub: 'did:plc:example123456789',
33
- };
@@ -1,84 +0,0 @@
1
- /**
2
- * Real Account Test Helpers
3
- * Utilities for testing with real AT Protocol accounts
4
- *
5
- * Note: For full token verification, you may need @atproto/api
6
- * Install it as a dev dependency if needed: npm install --save-dev @atproto/api
7
- */
8
- /**
9
- * Get test account from environment variables
10
- */
11
- export function getTestAccount() {
12
- const handle = process.env.TEST_BLUESKY_HANDLE;
13
- const did = process.env.TEST_BLUESKY_DID;
14
- const pdsUrl = process.env.TEST_BLUESKY_PDS || 'https://bsky.social';
15
- if (!handle || !did) {
16
- throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_DID must be set for integration tests. ' +
17
- 'Create a test account at https://bsky.app and set these environment variables.');
18
- }
19
- return { handle, did, pdsUrl };
20
- }
21
- /**
22
- * Verify access token is valid by making an authenticated request
23
- *
24
- * Note: This requires @atproto/api. For a simpler check, you can verify
25
- * the token structure or make a direct HTTP request with DPoP.
26
- */
27
- export async function verifyAccessToken(accessToken, did, pdsUrl) {
28
- try {
29
- // Simple validation: check token structure
30
- // In production, you'd make an actual API call with DPoP
31
- if (!accessToken || accessToken.length < 10) {
32
- return false;
33
- }
34
- // For full verification, you would:
35
- // 1. Create DPoP proof for the request
36
- // 2. Make authenticated request to PDS
37
- // 3. Verify response is successful
38
- // For now, just check basic structure
39
- return typeof accessToken === 'string' && accessToken.length > 0;
40
- }
41
- catch (error) {
42
- console.error('Token verification failed:', error);
43
- return false;
44
- }
45
- }
46
- /**
47
- * Create test config from environment variables
48
- */
49
- export function getTestConfig() {
50
- const clientMetadataUri = process.env.TEST_CLIENT_METADATA_URI;
51
- const clientId = process.env.TEST_CLIENT_ID || clientMetadataUri;
52
- const jwksUri = process.env.TEST_JWKS_URI;
53
- if (!clientMetadataUri) {
54
- throw new Error('TEST_CLIENT_METADATA_URI must be set. ' +
55
- 'This should be a publicly accessible URL to your OAuth client metadata JSON file.');
56
- }
57
- if (!jwksUri) {
58
- throw new Error('TEST_JWKS_URI must be set for confidential clients. ' +
59
- 'This should be a publicly accessible URL to your JWKS JSON file.');
60
- }
61
- return {
62
- clientMetadataUri,
63
- clientId: clientId,
64
- jwksUri: jwksUri,
65
- scope: process.env.TEST_SCOPE || 'atproto transition:generic',
66
- tokenEndpointAuthMethod: 'private_key_jwt',
67
- };
68
- }
69
- /**
70
- * Check if integration tests should run
71
- */
72
- export function shouldRunIntegrationTests() {
73
- return !!(process.env.TEST_BLUESKY_HANDLE &&
74
- process.env.TEST_BLUESKY_DID &&
75
- process.env.TEST_CLIENT_METADATA_URI);
76
- }
77
- /**
78
- * Get test redirect URI
79
- */
80
- export function getTestRedirectUri() {
81
- return (process.env.TEST_REDIRECT_URI ||
82
- process.env.PUBLIC_URL + '/oauth/callback' ||
83
- 'https://example.com/oauth/callback');
84
- }
@@ -1,118 +0,0 @@
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
- });
@@ -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
- }