atproto user agency toolkit for individuals and groups
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}