open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents
atprotocol
pds
cloudflare
1export interface RsaPublicJwk {
2 kty: string;
3 n: string;
4 e: string;
5 [key: string]: unknown;
6}
7
8export interface JwtHeader {
9 typ?: string;
10 alg?: string;
11 jwk?: RsaPublicJwk;
12 [key: string]: unknown;
13}
14
15export interface JwtPayload {
16 [key: string]: unknown;
17}
18
19export interface AccountRow {
20 id: number;
21 did: string;
22 handle: string | null;
23 jwk_thumbprint: string | null;
24 [key: string]: unknown;
25}
26
27export type AuthEnv = {
28 Variables: {
29 account: AccountRow;
30 };
31};
32
33export function base64urlEncode(buffer: ArrayBuffer | Uint8Array): string {
34 const bytes = new Uint8Array(buffer);
35 let binary = "";
36 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
37 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
38}
39
40export function base64urlDecode(str: string): Uint8Array {
41 const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
42 const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
43 const binary = atob(padded);
44 const bytes = new Uint8Array(binary.length);
45 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
46 return bytes;
47}
48
49export async function sha256Base64url(data: string | Uint8Array): Promise<string> {
50 const hash = await crypto.subtle.digest(
51 "SHA-256",
52 typeof data === "string" ? new TextEncoder().encode(data) : data,
53 );
54 return base64urlEncode(hash);
55}
56
57export function parseJwt(token: string): {
58 header: JwtHeader;
59 payload: JwtPayload;
60 signingInput: string;
61 signature: string;
62} {
63 const parts = token.split(".");
64 if (parts.length !== 3) {
65 throw new Error("invalid JWT: expected 3 parts");
66 }
67
68 const header = JSON.parse(
69 new TextDecoder().decode(base64urlDecode(parts[0])),
70 ) as JwtHeader;
71 const payload = JSON.parse(
72 new TextDecoder().decode(base64urlDecode(parts[1])),
73 ) as JwtPayload;
74
75 return {
76 header,
77 payload,
78 signingInput: `${parts[0]}.${parts[1]}`,
79 signature: parts[2],
80 };
81}
82
83export async function jwkThumbprint(jwk: RsaPublicJwk): Promise<string> {
84 const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n });
85 return sha256Base64url(canonical);
86}
87
88export async function validateAndImportKey(jwk: RsaPublicJwk): Promise<CryptoKey> {
89 if (jwk.kty !== "RSA") {
90 throw new Error("key must be RSA");
91 }
92 if (!jwk.n || !jwk.e) {
93 throw new Error("invalid RSA key: missing n or e");
94 }
95
96 let key: CryptoKey;
97 try {
98 key = await crypto.subtle.importKey(
99 "jwk",
100 { kty: jwk.kty, n: jwk.n, e: jwk.e },
101 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
102 true,
103 ["verify"],
104 );
105 } catch {
106 throw new Error("invalid RSA public key");
107 }
108
109 const exported = await crypto.subtle.exportKey("jwk", key);
110 if (
111 !("n" in exported) ||
112 typeof exported.n !== "string" ||
113 !("e" in exported) ||
114 typeof exported.e !== "string"
115 ) {
116 throw new Error("invalid RSA public key");
117 }
118
119 const nBase64 = exported.n.replace(/-/g, "+").replace(/_/g, "/");
120 const padded = nBase64 + "=".repeat((4 - (nBase64.length % 4)) % 4);
121 const modulusBits = atob(padded).length * 8;
122 if (modulusBits !== 4096) {
123 throw new Error(`key must be 4096-bit RSA (got ${modulusBits}-bit)`);
124 }
125
126 return key;
127}
128
129export async function validateDpopProof(
130 dpopJwt: string,
131 method: string,
132 url: string,
133 accessToken: string | null,
134): Promise<{ jwk: RsaPublicJwk; key: CryptoKey; thumbprint: string }> {
135 let jwt;
136 try {
137 jwt = parseJwt(dpopJwt);
138 } catch {
139 throw new Error("invalid DPoP proof: malformed JWT");
140 }
141
142 const { header, payload, signingInput, signature } = jwt;
143
144 if (header.typ !== "dpop+jwt") {
145 throw new Error("invalid DPoP proof: typ must be dpop+jwt");
146 }
147 if (header.alg !== "RS256") {
148 throw new Error("invalid DPoP proof: alg must be RS256");
149 }
150 if (!header.jwk) {
151 throw new Error("invalid DPoP proof: missing jwk");
152 }
153
154 const key = await validateAndImportKey(header.jwk);
155
156 if (!payload.jti) {
157 throw new Error("invalid DPoP proof: missing jti");
158 }
159 if (payload.htm !== method) {
160 throw new Error(`invalid DPoP proof: htm must be ${method}`);
161 }
162
163 const reqUrl = new URL(url);
164 const expectedHtu = reqUrl.origin + reqUrl.pathname;
165 if (payload.htu !== expectedHtu) {
166 throw new Error("invalid DPoP proof: htu does not match request URL");
167 }
168
169 if (!payload.iat || typeof payload.iat !== "number") {
170 throw new Error("invalid DPoP proof: missing or invalid iat");
171 }
172
173 const now = Math.floor(Date.now() / 1000);
174 if (Math.abs(now - payload.iat) > 300) {
175 throw new Error("invalid DPoP proof: iat too far from current time");
176 }
177
178 if (accessToken) {
179 if (typeof payload.ath !== "string") {
180 throw new Error("invalid DPoP proof: missing ath");
181 }
182 const expectedAth = await sha256Base64url(accessToken);
183 if (payload.ath !== expectedAth) {
184 throw new Error("invalid DPoP proof: ath does not match access token");
185 }
186 }
187
188 const sigBytes = base64urlDecode(signature);
189 const valid = await crypto.subtle.verify(
190 "RSASSA-PKCS1-v1_5",
191 key,
192 sigBytes,
193 new TextEncoder().encode(signingInput),
194 );
195 if (!valid) {
196 throw new Error("invalid DPoP proof: signature verification failed");
197 }
198
199 return { jwk: header.jwk, key, thumbprint: await jwkThumbprint(header.jwk) };
200}
201
202export async function validateAccessToken(
203 accessTokenStr: string,
204 dpopKey: CryptoKey,
205 serviceOrigin: string,
206 dpopThumbprint: string,
207 tosText: string,
208): Promise<JwtPayload> {
209 let jwt;
210 try {
211 jwt = parseJwt(accessTokenStr);
212 } catch {
213 throw new Error("invalid access token: malformed JWT");
214 }
215
216 const { header, payload, signingInput, signature } = jwt;
217
218 if (header.typ !== "wm+jwt") {
219 throw new Error("invalid access token: typ must be wm+jwt");
220 }
221 if (header.alg !== "RS256") {
222 throw new Error("invalid access token: alg must be RS256");
223 }
224 if (!payload.tos_hash) {
225 throw new Error("invalid access token: missing tos_hash");
226 }
227 if (payload.aud !== serviceOrigin) {
228 throw new Error("invalid access token: aud does not match service origin");
229 }
230
231 const cnf = payload.cnf;
232 if (!cnf || typeof cnf !== "object" || !("jkt" in cnf) || typeof cnf.jkt !== "string") {
233 throw new Error("invalid access token: missing cnf.jkt");
234 }
235 if (cnf.jkt !== dpopThumbprint) {
236 throw new Error("invalid access token: cnf.jkt does not match DPoP key");
237 }
238
239 const expectedTosHash = await sha256Base64url(tosText);
240 if (payload.tos_hash !== expectedTosHash) {
241 throw new Error("invalid access token: tos_hash does not match current terms");
242 }
243
244 const sigBytes = base64urlDecode(signature);
245 const valid = await crypto.subtle.verify(
246 "RSASSA-PKCS1-v1_5",
247 dpopKey,
248 sigBytes,
249 new TextEncoder().encode(signingInput),
250 );
251 if (!valid) {
252 throw new Error("invalid access token: signature verification failed");
253 }
254
255 return payload;
256}
257
258export function isValidHandle(handle: string): boolean {
259 return /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(handle) && handle.length <= 64;
260}
261
262export function extractBearerToken(authHeader: string | null): string | null {
263 if (!authHeader) {
264 return null;
265 }
266 const match = authHeader.match(/^DPoP\s+(.+)$/i);
267 return match ? match[1] : null;
268}