atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 134 lines 3.3 kB view raw
1import { Secp256k1Keypair, randomStr, verifySignature } from "@atproto/crypto"; 2 3const MINUTE = 60; 4const SERVICE_JWT_EXPIRY_SECONDS = 5 * MINUTE; 5 6let cachedKeypair: Secp256k1Keypair | null = null; 7let cachedSigningKey: string | null = null; 8 9export async function getSigningKeypair( 10 signingKey: string, 11): Promise<Secp256k1Keypair> { 12 if (cachedKeypair && cachedSigningKey === signingKey) { 13 return cachedKeypair; 14 } 15 cachedKeypair = await Secp256k1Keypair.import(signingKey); 16 cachedSigningKey = signingKey; 17 return cachedKeypair; 18} 19 20export interface ServiceJwtPayload { 21 iss: string; 22 aud: string; 23 exp: number; 24 iat?: number; 25 lxm?: string; 26 jti?: string; 27} 28 29type ServiceJwtParams = { 30 iss: string; 31 aud: string; 32 lxm: string | null; 33 keypair: Secp256k1Keypair; 34}; 35 36function jsonToB64Url(json: Record<string, unknown>): string { 37 return Buffer.from(JSON.stringify(json)).toString("base64url"); 38} 39 40function noUndefinedVals<T extends Record<string, unknown>>( 41 obj: T, 42): Partial<T> { 43 const result: Partial<T> = {}; 44 for (const [key, val] of Object.entries(obj)) { 45 if (val !== undefined) { 46 result[key as keyof T] = val as T[keyof T]; 47 } 48 } 49 return result; 50} 51 52export async function createServiceJwt( 53 params: ServiceJwtParams, 54): Promise<string> { 55 const { iss, aud, keypair } = params; 56 const iat = Math.floor(Date.now() / 1000); 57 const exp = iat + SERVICE_JWT_EXPIRY_SECONDS; 58 const lxm = params.lxm ?? undefined; 59 const jti = randomStr(16, "hex"); 60 61 const header = { 62 typ: "JWT", 63 alg: keypair.jwtAlg, 64 }; 65 66 const payload = noUndefinedVals({ 67 iat, 68 iss, 69 aud, 70 exp, 71 lxm, 72 jti, 73 }); 74 75 const toSignStr = `${jsonToB64Url(header)}.${jsonToB64Url(payload as Record<string, unknown>)}`; 76 const toSign = Buffer.from(toSignStr, "utf8"); 77 const sig = Buffer.from(await keypair.sign(toSign)); 78 79 return `${toSignStr}.${sig.toString("base64url")}`; 80} 81 82export async function verifyServiceJwt( 83 token: string, 84 signingKey: string, 85 expectedAudience: string, 86 expectedIssuer: string, 87): Promise<ServiceJwtPayload> { 88 const parts = token.split("."); 89 if (parts.length !== 3) { 90 throw new Error("Invalid JWT format"); 91 } 92 93 const headerB64 = parts[0]!; 94 const payloadB64 = parts[1]!; 95 const signatureB64 = parts[2]!; 96 97 const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); 98 if (header.alg !== "ES256K") { 99 throw new Error(`Unsupported algorithm: ${header.alg}`); 100 } 101 102 const payload: ServiceJwtPayload = JSON.parse( 103 Buffer.from(payloadB64, "base64url").toString(), 104 ); 105 106 const now = Math.floor(Date.now() / 1000); 107 if (payload.exp && payload.exp < now) { 108 throw new Error("Token expired"); 109 } 110 111 if (payload.aud !== expectedAudience) { 112 throw new Error(`Invalid audience: expected ${expectedAudience}`); 113 } 114 115 if (payload.iss !== expectedIssuer) { 116 throw new Error(`Invalid issuer: expected ${expectedIssuer}`); 117 } 118 119 const keypair = await getSigningKeypair(signingKey); 120 const msgBytes = new Uint8Array( 121 Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), 122 ); 123 const sigBytes = new Uint8Array(Buffer.from(signatureB64, "base64url")); 124 125 const isValid = await verifySignature(keypair.did(), msgBytes, sigBytes, { 126 allowMalleableSig: true, 127 }); 128 129 if (!isValid) { 130 throw new Error("Invalid signature"); 131 } 132 133 return payload; 134}