Suite of AT Protocol TypeScript libraries built on web standards
20
fork

Configure Feed

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

at main 281 lines 8.1 kB view raw
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};