@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
package/lib/dpop.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dpop.test.d.ts","sourceRoot":"","sources":["../src/dpop.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
package/lib/dpop.test.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bluesky/AT Protocol OAuth 2.0 connector implementation
|
|
3
|
+
* Implements PAR (Pushed Authorization Requests), PKCE, and DPoP
|
|
4
|
+
* https://atproto.com/specs/oauth
|
|
5
|
+
*/
|
|
6
|
+
import type { CreateConnector, SocialConnector } from '@logto/connector-kit';
|
|
7
|
+
/**
|
|
8
|
+
* Create the Bluesky connector instance
|
|
9
|
+
*/
|
|
10
|
+
declare const createBlueskyConnector: CreateConnector<SocialConnector>;
|
|
11
|
+
export default createBlueskyConnector;
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAIV,eAAe,EACf,eAAe,EAChB,MAAM,sBAAsB,CAAC;AAkmB9B;;GAEG;AACH,QAAA,MAAM,sBAAsB,EAAE,eAAe,CAAC,eAAe,CAW5D,CAAC;AAEF,eAAe,sBAAsB,CAAC"}
|