A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
at main 223 lines 6.9 kB view raw
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}