A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
1// @pds/core/auth - Authentication utilities for AT Protocol
2// JWT creation, verification, and service auth
3
4import {
5 base64UrlDecode,
6 base64UrlEncode,
7 bytesToHex,
8 sign,
9} from './crypto.js';
10
11/**
12 * Decoded JWT payload for session tokens
13 * @typedef {Object} JwtPayload
14 * @property {string} [scope] - Token scope (e.g., "com.atproto.access")
15 * @property {string} sub - Subject DID (the authenticated user)
16 * @property {string} [aud] - Audience (for refresh tokens, should match sub)
17 * @property {number} [iat] - Issued-at timestamp (Unix seconds)
18 * @property {number} [exp] - Expiration timestamp (Unix seconds)
19 * @property {string} [jti] - Unique token identifier
20 */
21
22/**
23 * Create HMAC-SHA256 signature for JWT
24 * @param {string} data - Data to sign (header.payload)
25 * @param {string} secret - Secret key
26 * @returns {Promise<string>} Base64url-encoded signature
27 */
28async function hmacSign(data, secret) {
29 const key = await crypto.subtle.importKey(
30 'raw',
31 /** @type {BufferSource} */ (new TextEncoder().encode(secret)),
32 { name: 'HMAC', hash: 'SHA-256' },
33 false,
34 ['sign'],
35 );
36 const sig = await crypto.subtle.sign(
37 'HMAC',
38 key,
39 /** @type {BufferSource} */ (new TextEncoder().encode(data)),
40 );
41 return base64UrlEncode(new Uint8Array(sig));
42}
43
44/**
45 * Create an access JWT for ATProto
46 * @param {string} did - User's DID (subject and audience)
47 * @param {string} secret - JWT signing secret
48 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours)
49 * @returns {Promise<string>} Signed JWT
50 */
51export async function createAccessJwt(did, secret, expiresIn = 7200) {
52 const header = { typ: 'at+jwt', alg: 'HS256' };
53 const now = Math.floor(Date.now() / 1000);
54 const payload = {
55 scope: 'com.atproto.access',
56 sub: did,
57 aud: did,
58 iat: now,
59 exp: now + expiresIn,
60 };
61
62 const headerB64 = base64UrlEncode(
63 new TextEncoder().encode(JSON.stringify(header)),
64 );
65 const payloadB64 = base64UrlEncode(
66 new TextEncoder().encode(JSON.stringify(payload)),
67 );
68 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret);
69
70 return `${headerB64}.${payloadB64}.${signature}`;
71}
72
73/**
74 * Create a refresh JWT for ATProto
75 * @param {string} did - User's DID (subject and audience)
76 * @param {string} secret - JWT signing secret
77 * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours)
78 * @returns {Promise<string>} Signed JWT
79 */
80export async function createRefreshJwt(did, secret, expiresIn = 86400) {
81 const header = { typ: 'refresh+jwt', alg: 'HS256' };
82 const now = Math.floor(Date.now() / 1000);
83 // Generate random jti (token ID)
84 const jtiBytes = new Uint8Array(32);
85 crypto.getRandomValues(jtiBytes);
86 const jti = base64UrlEncode(jtiBytes);
87
88 const payload = {
89 scope: 'com.atproto.refresh',
90 sub: did,
91 aud: did,
92 jti,
93 iat: now,
94 exp: now + expiresIn,
95 };
96
97 const headerB64 = base64UrlEncode(
98 new TextEncoder().encode(JSON.stringify(header)),
99 );
100 const payloadB64 = base64UrlEncode(
101 new TextEncoder().encode(JSON.stringify(payload)),
102 );
103 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret);
104
105 return `${headerB64}.${payloadB64}.${signature}`;
106}
107
108/**
109 * Verify and decode a JWT (shared logic)
110 * @param {string} jwt - JWT string to verify
111 * @param {string} secret - JWT signing secret
112 * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt')
113 * @returns {Promise<{header: {typ: string, alg: string}, payload: JwtPayload}>} Decoded header and payload
114 * @throws {Error} If token is invalid, expired, or wrong type
115 */
116async function verifyJwt(jwt, secret, expectedType) {
117 const parts = jwt.split('.');
118 if (parts.length !== 3) {
119 throw new Error('Invalid JWT format');
120 }
121
122 const [headerB64, payloadB64, signatureB64] = parts;
123
124 // Verify signature
125 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret);
126 if (signatureB64 !== expectedSig) {
127 throw new Error('Invalid signature');
128 }
129
130 // Decode header and payload
131 const header = JSON.parse(
132 new TextDecoder().decode(base64UrlDecode(headerB64)),
133 );
134 const payload = JSON.parse(
135 new TextDecoder().decode(base64UrlDecode(payloadB64)),
136 );
137
138 // Check token type
139 if (header.typ !== expectedType) {
140 throw new Error(`Invalid token type: expected ${expectedType}`);
141 }
142
143 // Check expiration
144 const now = Math.floor(Date.now() / 1000);
145 if (payload.exp && payload.exp < now) {
146 throw new Error('Token expired');
147 }
148
149 return { header, payload };
150}
151
152/**
153 * Verify and decode an access JWT
154 * @param {string} jwt - JWT string to verify
155 * @param {string} secret - JWT signing secret
156 * @returns {Promise<JwtPayload>} Decoded payload
157 * @throws {Error} If token is invalid, expired, or wrong type
158 */
159export async function verifyAccessJwt(jwt, secret) {
160 const { payload } = await verifyJwt(jwt, secret, 'at+jwt');
161 return payload;
162}
163
164/**
165 * Verify and decode a refresh JWT
166 * @param {string} jwt - JWT string to verify
167 * @param {string} secret - JWT signing secret
168 * @returns {Promise<JwtPayload>} Decoded payload
169 * @throws {Error} If token is invalid, expired, or wrong type
170 */
171export async function verifyRefreshJwt(jwt, secret) {
172 const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt');
173
174 // Validate audience matches subject (token intended for this user)
175 if (payload.aud && payload.aud !== payload.sub) {
176 throw new Error('Invalid audience');
177 }
178
179 return payload;
180}
181
182/**
183 * Create a service auth JWT signed with ES256 (P-256)
184 * Used for proxying requests to AppView
185 * @param {Object} params - JWT parameters
186 * @param {string} params.iss - Issuer DID (PDS DID)
187 * @param {string} params.aud - Audience DID (AppView DID)
188 * @param {string|null} params.lxm - Lexicon method being called
189 * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey
190 * @returns {Promise<string>} Signed JWT
191 */
192export async function createServiceJwt({ iss, aud, lxm, signingKey }) {
193 const header = { typ: 'JWT', alg: 'ES256' };
194 const now = Math.floor(Date.now() / 1000);
195
196 // Generate random jti
197 const jtiBytes = new Uint8Array(16);
198 crypto.getRandomValues(jtiBytes);
199 const jti = bytesToHex(jtiBytes);
200
201 /** @type {{ iss: string, aud: string, exp: number, iat: number, jti: string, lxm?: string }} */
202 const payload = {
203 iss,
204 aud,
205 exp: now + 60, // 1 minute expiration
206 iat: now,
207 jti,
208 };
209 if (lxm) payload.lxm = lxm;
210
211 const headerB64 = base64UrlEncode(
212 new TextEncoder().encode(JSON.stringify(header)),
213 );
214 const payloadB64 = base64UrlEncode(
215 new TextEncoder().encode(JSON.stringify(payload)),
216 );
217 const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
218
219 const sig = await sign(signingKey, toSign);
220 const sigB64 = base64UrlEncode(sig);
221
222 return `${headerB64}.${payloadB64}.${sigB64}`;
223}