Suite of AT Protocol TypeScript libraries built on web standards
1import * as ui8 from "@atp/bytes";
2import * as common from "@atp/common";
3import { MINUTE } from "@atp/common";
4import * as crypto from "@atp/crypto";
5import { AuthRequiredError } from "./errors.ts";
6
7/**
8 * Parameters for service JWT creation
9 * @prop iss The issuer of the key (corresponds to user DID)
10 * @prop aud The intended audience of the key, the service it's intended for
11 * @prop iat When the key was issued at
12 * @prop exp When the key expires
13 * @prop lxm Lexicon (XRPC) endpoints the key is allowed to be used for
14 * @prop keypair Signing key to be used to create the JWT token
15 */
16export type ServiceJwtParams = {
17 iss: string;
18 aud: string;
19 iat?: number;
20 exp?: number;
21 lxm: string | null;
22 keypair: crypto.Keypair;
23};
24
25/**
26 * Headers of a service JWT token
27 * @prop alg Algorithm used for the JWT token's encoding
28 */
29export type ServiceJwtHeaders = {
30 alg: string;
31} & Record<string, unknown>;
32
33/**
34 * Parameters for service JWT creation
35 * @prop iss The issuer of the token (corresponds to user DID)
36 * @prop aud The intended audience of the token, the service it's intended for
37 * @prop exp When the key expires
38 * @prop lxm Lexicon (XRPC) endpoints the token is allowed to be used for
39 * @prop jti JWT Identifier
40 */
41export type ServiceJwtPayload = {
42 iss: string;
43 aud: string;
44 exp: number;
45 lxm?: string;
46 jti?: string;
47};
48
49/**
50 * Create a JWT token string for service auth
51 * @param params Information and permissions given to the service JWT token
52 */
53export const createServiceJwt = (
54 params: ServiceJwtParams,
55): string => {
56 const { iss, aud, keypair } = params;
57 const iat = params.iat ?? Math.floor(Date.now() / 1e3);
58 const exp = params.exp ?? iat + MINUTE / 1e3;
59 const lxm = params.lxm ?? undefined;
60 const jti = crypto.randomStr(16, "hex");
61 const header = {
62 typ: "JWT",
63 alg: keypair.jwtAlg,
64 };
65 const payload = common.noUndefinedVals({
66 iat,
67 iss,
68 aud,
69 exp,
70 lxm,
71 jti,
72 });
73 const toSignStr = `${jsonToB64Url(header)}.${jsonToB64Url(payload)}`;
74 const toSign = ui8.fromString(toSignStr, "utf8");
75 const sig = keypair.sign(toSign);
76 return `${toSignStr}.${ui8.toString(sig, "base64url")}`;
77};
78
79/**
80 * Creates authorization headers containing a service JWT.
81 * Useful for making authenticated HTTP requests to other services.
82 *
83 * @param params - Parameters for creating the JWT
84 * @returns Object containing authorization header with Bearer token
85 *
86 * @example
87 * ```typescript
88 * const auth = await createServiceAuthHeaders({
89 * iss: 'did:example:issuer',
90 * aud: 'did:example:audience',
91 * keypair: myKeypair
92 * });
93 * fetch(url, { headers: auth.headers });
94 * ```
95 */
96export const createServiceAuthHeaders = (
97 params: ServiceJwtParams,
98): { headers: { authorization: string } } => {
99 const jwt = createServiceJwt(params);
100 return {
101 headers: { authorization: `Bearer ${jwt}` },
102 };
103};
104
105const jsonToB64Url = (json: Record<string, unknown>): string => {
106 return common.utf8ToB64Url(JSON.stringify(json));
107};
108
109/** Verify a message signature against a key */
110export type VerifySignatureWithKeyFn = (
111 key: string,
112 msgBytes: Uint8Array,
113 sigBytes: Uint8Array,
114 alg: string,
115) => boolean;
116
117/**
118 * Verify a JWT token is valid against the context in which
119 * it's being used, including the lxm matching the current endpoint,
120 * the aud matching the service DID, and the key itself matching
121 * the signing key of the DID who claims to have issued it
122 * @param jwtStr The JWT token being used
123 * @param ownDid The DID of the current service, null indicates to skip the audience check
124 * @param lxm The lexicon permissions of the JWT token, null indicates to skip the lxm check
125 * @param getSigningKey A function to get the signing key of the issuer
126 * @param verifySignatureWithKey A method to verify the signature with the JWT token,
127 */
128export const verifyJwt = async (
129 jwtStr: string,
130 ownDid: string | null,
131 lxm: string | null,
132 getSigningKey: (
133 iss: string,
134 forceRefresh: boolean,
135 ) => Promise<string> | string,
136 verifySignatureWithKey: VerifySignatureWithKeyFn =
137 cryptoVerifySignatureWithKey,
138): Promise<ServiceJwtPayload> => {
139 const parts = jwtStr.split(".");
140 if (parts.length !== 3) {
141 throw new AuthRequiredError("poorly formatted jwt", "BadJwt");
142 }
143
144 const header = parseHeader(parts[0]);
145
146 // The spec does not describe what to do with the "typ" claim. We can,
147 // however, forbid some values that are not compatible with our use case.
148 if (
149 // service tokens are not OAuth 2.0 access tokens
150 // https://datatracker.ietf.org/doc/html/rfc9068
151 header["typ"] === "at+jwt" ||
152 // "refresh+jwt" is a non-standard type used by atproto packages
153 header["typ"] === "refresh+jwt" ||
154 // "DPoP" proofs are not meant to be used as service tokens
155 // https://datatracker.ietf.org/doc/html/rfc9449
156 header["typ"] === "dpop+jwt"
157 ) {
158 throw new AuthRequiredError(
159 `Invalid jwt type "${header["typ"]}"`,
160 "BadJwtType",
161 );
162 }
163
164 const payload = parsePayload(parts[1]);
165 const sig = parts[2];
166
167 if (Date.now() / 1000 > payload.exp) {
168 throw new AuthRequiredError("jwt expired", "JwtExpired");
169 }
170 if (ownDid !== null && payload.aud !== ownDid) {
171 throw new AuthRequiredError(
172 "jwt audience does not match service did",
173 "BadJwtAudience",
174 );
175 }
176 if (lxm !== null && payload.lxm !== lxm) {
177 throw new AuthRequiredError(
178 payload.lxm !== undefined
179 ? `bad jwt lexicon method ("lxm"). must match: ${lxm}`
180 : `missing jwt lexicon method ("lxm"). must match: ${lxm}`,
181 "BadJwtLexiconMethod",
182 );
183 }
184
185 const msgBytes = ui8.fromString(parts.slice(0, 2).join("."), "utf8");
186 const sigBytes = ui8.fromString(sig, "base64url");
187
188 const signingKey = await getSigningKey(payload.iss, false);
189 const { alg } = header;
190
191 let validSig: boolean;
192 try {
193 validSig = verifySignatureWithKey(
194 signingKey,
195 msgBytes,
196 sigBytes,
197 alg,
198 );
199 } catch {
200 throw new AuthRequiredError(
201 "could not verify jwt signature",
202 "BadJwtSignature",
203 );
204 }
205
206 if (!validSig) {
207 // get fresh signing key in case it failed due to a recent rotation
208 const freshSigningKey = await getSigningKey(payload.iss, true);
209 try {
210 validSig = freshSigningKey !== signingKey
211 ? verifySignatureWithKey(
212 freshSigningKey,
213 msgBytes,
214 sigBytes,
215 alg,
216 )
217 : false;
218 } catch {
219 throw new AuthRequiredError(
220 "could not verify jwt signature",
221 "BadJwtSignature",
222 );
223 }
224 }
225
226 if (!validSig) {
227 throw new AuthRequiredError(
228 "jwt signature does not match jwt issuer",
229 "BadJwtSignature",
230 );
231 }
232
233 return payload;
234};
235
236/**
237 * Default method to verify a JWT signature against a key.
238 * @param key to verify JWT token against
239 * @param msgBytes Corresponding message
240 * @param sigBytes JWT signature bytes to verify
241 * @param alg Encoding algorithm for JWT signature
242 */
243export const cryptoVerifySignatureWithKey: VerifySignatureWithKeyFn = (
244 key: string,
245 msgBytes: Uint8Array,
246 sigBytes: Uint8Array,
247 alg: string,
248) => {
249 return crypto.verifySignature(key, msgBytes, sigBytes, {
250 jwtAlg: alg,
251 allowMalleableSig: true,
252 });
253};
254
255const parseB64UrlToJson = (b64: string) => {
256 return JSON.parse(common.b64UrlToUtf8(b64));
257};
258
259const parseHeader = (b64: string): ServiceJwtHeaders => {
260 const header = parseB64UrlToJson(b64);
261 if (!header || typeof header !== "object" || typeof header.alg !== "string") {
262 throw new AuthRequiredError("poorly formatted jwt", "BadJwt");
263 }
264 return header;
265};
266
267const parsePayload = (b64: string): ServiceJwtPayload => {
268 const payload = parseB64UrlToJson(b64);
269 if (
270 !payload ||
271 typeof payload !== "object" ||
272 typeof payload.iss !== "string" ||
273 typeof payload.aud !== "string" ||
274 typeof payload.exp !== "number" ||
275 (payload.lxm && typeof payload.lxm !== "string") ||
276 (payload.nonce && typeof payload.nonce !== "string")
277 ) {
278 throw new AuthRequiredError("poorly formatted jwt", "BadJwt");
279 }
280 return payload;
281};