[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
1
fork

Configure Feed

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

at main 575 lines 17 kB view raw
1import * as jose from "jose"; 2import { 3 AuthRequiredError, 4 AuthResult, 5 MethodAuthContext, 6 parseReqNsid, 7 verifyJwt, 8} from "@atp/xrpc-server"; 9import { DataPlane } from "./data-plane/index.ts"; 10 11type StandardAuthOpts = { 12 skipAudCheck?: boolean; 13 lxmCheck?: (method?: string) => boolean; 14}; 15 16export enum RoleStatus { 17 Valid, 18 Invalid, 19 Missing, 20} 21 22export type NullOutput = { 23 credentials: { 24 type: "none"; 25 iss: null; 26 }; 27 artifacts: unknown; 28}; 29 30export type StandardOutput = { 31 credentials: { 32 type: "standard"; 33 aud: string; 34 iss: string; 35 }; 36 artifacts: unknown; 37}; 38 39export type RoleOutput = { 40 credentials: { 41 type: "role"; 42 admin: boolean; 43 }; 44 artifacts: unknown; 45}; 46 47// NOTE this is not currently used, but is here for future use when we support mod services in future 48export type ModServiceOutput = { 49 credentials: { 50 type: "mod_service"; 51 aud: string; 52 iss: string; 53 }; 54 artifacts: unknown; 55}; 56 57const ALLOWED_AUTH_SCOPES = new Set([ 58 "com.atproto.access", 59 "com.atproto.appPass", 60 "com.atproto.appPassPrivileged", 61]); 62 63export type AuthVerifierOpts = { 64 ownDid: string; 65 alternateAudienceDids: string[]; 66 modServiceDid: string; 67 adminPasses: string[]; 68 entrywayJwtPublicKey?: CryptoKey; 69}; 70 71export interface ExtendedAuthVerifier { 72 optionalStandardOrRole: ( 73 ctx: MethodAuthContext, 74 ) => Promise<StandardOutput | RoleOutput | NullOutput>; 75 standardOrRole: ( 76 ctx: MethodAuthContext, 77 ) => Promise<StandardOutput | RoleOutput>; 78 standard: (ctx: MethodAuthContext) => Promise<StandardOutput>; 79 role: (ctx: MethodAuthContext) => RoleOutput; 80 modService: (ctx: MethodAuthContext) => Promise<ModServiceOutput>; 81 roleOrModService: ( 82 ctx: MethodAuthContext, 83 ) => Promise<RoleOutput | ModServiceOutput>; 84 parseCreds: ( 85 auth: StandardOutput | RoleOutput | NullOutput | ModServiceOutput, 86 ) => { 87 viewer: string | null; 88 includeTakedowns: boolean; 89 include3pBlocks: boolean; 90 canPerformTakedown: boolean; 91 }; 92 standardOptional: ( 93 ctx: MethodAuthContext, 94 ) => Promise<StandardOutput | NullOutput>; 95 standardOptionalParameterized: (opts: StandardAuthOpts) => ( 96 ctx: MethodAuthContext, 97 ) => Promise<StandardOutput | NullOutput>; 98 entrywaySession: (reqCtx: MethodAuthContext) => Promise<StandardOutput>; 99 parseRoleCreds: (req: Request) => { 100 status: RoleStatus; 101 admin: boolean; 102 type?: "role"; 103 }; 104 verifyServiceJwt: ( 105 reqCtx: MethodAuthContext, 106 opts: { 107 iss: string[] | null; 108 aud: string | null; 109 lxmCheck?: (method?: string) => boolean; 110 }, 111 ) => Promise<{ iss: string; aud: string }>; 112 isModService: (iss: string) => boolean; 113 nullCreds: () => NullOutput; 114} 115 116export interface AuthVerifier extends ExtendedAuthVerifier { 117 (ctx: MethodAuthContext): Promise<AuthResult>; 118 ownDid: string; 119 standardAudienceDids: Set<string>; 120 modServiceDid: string; 121 adminPasses: Set<string>; 122 entrywayJwtPublicKey?: CryptoKey; 123} 124 125export function createAuthVerifier( 126 dataplane: DataPlane, 127 opts: AuthVerifierOpts, 128): AuthVerifier { 129 const impl = new AuthVerifierImpl(dataplane, opts); 130 131 // Create the callable function 132 const verifier = ((ctx: MethodAuthContext): Promise<AuthResult> => { 133 return impl.optionalStandardOrRole(ctx); 134 }) as unknown as AuthVerifier; 135 136 // Add properties and methods 137 verifier.ownDid = opts.ownDid; 138 verifier.standardAudienceDids = new Set([ 139 opts.ownDid, 140 ...opts.alternateAudienceDids, 141 ]); 142 verifier.modServiceDid = opts.modServiceDid; 143 verifier.adminPasses = new Set(opts.adminPasses); 144 verifier.entrywayJwtPublicKey = opts.entrywayJwtPublicKey; 145 146 // Add all methods from impl 147 verifier.optionalStandardOrRole = (ctx: MethodAuthContext) => { 148 if ("c" in ctx) { 149 return impl.optionalStandardOrRole(ctx); 150 } 151 return impl.optionalStandardOrRole(ctx as MethodAuthContext); 152 }; 153 verifier.standardOrRole = impl.standardOrRole; 154 verifier.standard = impl.standard; 155 verifier.role = impl.role; 156 verifier.modService = impl.modService; 157 verifier.roleOrModService = impl.roleOrModService; 158 verifier.parseCreds = impl.parseCreds.bind(impl); 159 verifier.standardOptional = impl.standardOptional; 160 verifier.standardOptionalParameterized = impl.standardOptionalParameterized; 161 verifier.entrywaySession = impl.entrywaySession.bind(impl); 162 verifier.parseRoleCreds = impl.parseRoleCreds.bind(impl); 163 verifier.verifyServiceJwt = impl.verifyServiceJwt.bind(impl); 164 verifier.isModService = impl.isModService.bind(impl); 165 verifier.nullCreds = impl.nullCreds.bind(impl); 166 167 return verifier as AuthVerifier; 168} 169 170// Private implementation class 171class AuthVerifierImpl { 172 public ownDid: string; 173 public standardAudienceDids: Set<string>; 174 public modServiceDid: string; 175 private adminPasses: Set<string>; 176 private entrywayJwtPublicKey?: CryptoKey; 177 178 constructor( 179 public dataplane: DataPlane, 180 opts: AuthVerifierOpts, 181 ) { 182 this.ownDid = opts.ownDid; 183 this.standardAudienceDids = new Set([ 184 opts.ownDid, 185 ...opts.alternateAudienceDids, 186 ]); 187 this.modServiceDid = opts.modServiceDid; 188 this.adminPasses = new Set(opts.adminPasses); 189 this.entrywayJwtPublicKey = opts.entrywayJwtPublicKey; 190 } 191 192 // verifiers (arrow fns to preserve scope) 193 standardOptionalParameterized = 194 (opts: StandardAuthOpts) => 195 async (ctx: MethodAuthContext): Promise<StandardOutput | NullOutput> => { 196 if (isBasicToken(ctx.req)) { 197 const aud = this.ownDid; 198 const iss = ctx.req.headers.get("appview-as-did"); 199 if (typeof iss !== "string" || !iss.startsWith("did:")) { 200 throw new AuthRequiredError("bad issuer"); 201 } 202 if (!this.parseRoleCreds(ctx.req).admin) { 203 throw new AuthRequiredError("bad credentials"); 204 } 205 return { 206 credentials: { type: "standard", iss, aud }, 207 artifacts: null, 208 }; 209 } else if (isBearerToken(ctx.req)) { 210 const token = bearerTokenFromReq(ctx.req); 211 const header = token ? jose.decodeProtectedHeader(token) : undefined; 212 if (header?.typ === "at+jwt") { 213 if (opts.skipAudCheck) { 214 throw new AuthRequiredError("Malformed token", "InvalidToken"); 215 } 216 return this.entrywaySession(ctx); 217 } 218 219 const { iss, aud } = await this.verifyServiceJwt(ctx, { 220 lxmCheck: opts.lxmCheck, 221 iss: null, 222 aud: null, 223 }); 224 if (!opts.skipAudCheck && !this.standardAudienceDids.has(aud)) { 225 throw new AuthRequiredError( 226 "jwt audience does not match service did", 227 "BadJwtAudience", 228 ); 229 } 230 return { 231 credentials: { 232 type: "standard", 233 iss, 234 aud, 235 }, 236 artifacts: null, 237 }; 238 } else { 239 return this.nullCreds(); 240 } 241 }; 242 243 standardOptional: ( 244 ctx: MethodAuthContext, 245 ) => Promise<StandardOutput | NullOutput> = this 246 .standardOptionalParameterized({}); 247 248 standard = async (ctx: MethodAuthContext): Promise<StandardOutput> => { 249 const output = await this.standardOptional(ctx); 250 if (output.credentials.type === "none") { 251 throw new AuthRequiredError(undefined, "AuthMissing"); 252 } 253 return output as StandardOutput; 254 }; 255 256 role = (ctx: MethodAuthContext): RoleOutput => { 257 const creds = this.parseRoleCreds(ctx.req); 258 if (creds.status !== RoleStatus.Valid) { 259 throw new AuthRequiredError(); 260 } 261 return { 262 credentials: { 263 ...creds, 264 type: "role", 265 }, 266 artifacts: null, 267 }; 268 }; 269 270 standardOrRole = async ( 271 ctx: MethodAuthContext, 272 ): Promise<StandardOutput | RoleOutput> => { 273 if (isBearerToken(ctx.req)) { 274 return await this.standard(ctx); 275 } else { 276 const creds = this.parseRoleCreds(ctx.req); 277 if (creds.status !== RoleStatus.Valid) { 278 throw new AuthRequiredError(); 279 } 280 return { 281 credentials: { 282 ...creds, 283 type: "role" as const, 284 }, 285 artifacts: null, 286 }; 287 } 288 }; 289 290 optionalStandardOrRole = async ( 291 ctx: MethodAuthContext, 292 ): Promise<StandardOutput | RoleOutput | NullOutput> => { 293 if (isBearerToken(ctx.req)) { 294 return await this.standard(ctx); 295 } else { 296 const creds = this.parseRoleCreds(ctx.req); 297 if (creds.status === RoleStatus.Valid) { 298 return { 299 credentials: { 300 ...creds, 301 type: "role", 302 }, 303 artifacts: null, 304 }; 305 } else if (creds.status === RoleStatus.Missing) { 306 return this.nullCreds(); 307 } else { 308 throw new AuthRequiredError(); 309 } 310 } 311 }; 312 313 // @NOTE this auth verifier method is not recommended to be implemented by most appviews 314 // this is a short term fix to remove proxy load from Bluesky's PDS and in line with possible 315 // future plans to have the client talk directly with the appview 316 entrywaySession = async ( 317 ctx: MethodAuthContext, 318 ): Promise<StandardOutput> => { 319 const token = bearerTokenFromReq(ctx.req); 320 if (!token) { 321 throw new AuthRequiredError(undefined, "AuthMissing"); 322 } 323 324 if (!this.entrywayJwtPublicKey) { 325 throw new AuthRequiredError("Malformed token", "InvalidToken"); 326 } 327 328 const res = await jose 329 .jwtVerify(token, this.entrywayJwtPublicKey) 330 .catch((err) => { 331 if (err?.["code"] === "ERR_JWT_EXPIRED") { 332 throw new AuthRequiredError("Token has expired", "ExpiredToken"); 333 } 334 throw new AuthRequiredError( 335 "Token could not be verified", 336 "InvalidToken", 337 ); 338 }); 339 340 const { sub, aud, scope } = res.payload; 341 if (typeof sub !== "string" || !sub.startsWith("did:")) { 342 throw new AuthRequiredError("Malformed token", "InvalidToken"); 343 } else if ( 344 typeof aud !== "string" || 345 !aud.startsWith("did:web:") 346 ) { 347 throw new AuthRequiredError("Bad token aud", "InvalidToken"); 348 } else if (typeof scope !== "string" || !ALLOWED_AUTH_SCOPES.has(scope)) { 349 throw new AuthRequiredError("Bad token scope", "InvalidToken"); 350 } 351 352 return { 353 credentials: { 354 type: "standard", 355 aud: this.ownDid, 356 iss: sub, 357 }, 358 artifacts: null, 359 }; 360 }; 361 362 modService = async (ctx: MethodAuthContext): Promise<ModServiceOutput> => { 363 const { iss, aud } = await this.verifyServiceJwt(ctx, { 364 aud: this.ownDid, 365 iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`], 366 }); 367 return { 368 credentials: { type: "mod_service", aud, iss }, 369 artifacts: null, 370 }; 371 }; 372 373 roleOrModService = async ( 374 reqCtx: MethodAuthContext, 375 ): Promise<RoleOutput | ModServiceOutput> => { 376 if (isBearerToken(reqCtx.req)) { 377 return await this.modService(reqCtx); 378 } else { 379 const creds = this.parseRoleCreds(reqCtx.req); 380 if (creds.status !== RoleStatus.Valid) { 381 throw new AuthRequiredError(); 382 } 383 return { 384 credentials: { 385 ...creds, 386 type: "role" as const, 387 }, 388 artifacts: null, 389 }; 390 } 391 }; 392 393 parseRoleCreds( 394 req: Request, 395 ): { status: RoleStatus; admin: boolean; type?: "role" } { 396 const parsed = parseBasicAuth(req.headers.get("Authorization") || ""); 397 const { Missing, Valid, Invalid } = RoleStatus; 398 if (!parsed) { 399 return { status: Missing, admin: false }; 400 } 401 const { username, password } = parsed; 402 if (username === "admin" && this.adminPasses.has(password)) { 403 return { status: Valid, admin: true, type: "role" as const }; 404 } 405 return { status: Invalid, admin: false }; 406 } 407 408 // @NOTE this is not currently used, but is here for future use when we support mod services in future 409 // and potentially for payment providers 410 async verifyServiceJwt( 411 reqCtx: MethodAuthContext, 412 opts: { 413 iss: string[] | null; 414 aud: string | null; 415 lxmCheck?: (method?: string) => boolean; 416 }, 417 ) { 418 const getSigningKey = async ( 419 iss: string, 420 _forceRefresh: boolean, // @TODO consider propagating to dataplane 421 ): Promise<string> => { 422 if (opts.iss !== null && !opts.iss.includes(iss)) { 423 throw new AuthRequiredError("Untrusted issuer", "UntrustedIss"); 424 } 425 const [did, serviceId] = iss.split("#"); 426 const keyId = serviceId === "atproto_labeler" 427 ? "atproto_label" 428 : "atproto"; 429 try { 430 const identity = await this.dataplane.identity.getByDid(did); 431 432 const keys = JSON.parse(identity.keys) as Record<string, { 433 Type: string; 434 PublicKeyMultibase: string; 435 }>; 436 const key = keys[keyId]; 437 438 if (!key || !key.PublicKeyMultibase) { 439 throw new AuthRequiredError("missing or bad key"); 440 } 441 442 return `did:key:${key.PublicKeyMultibase}`; 443 } catch (err) { 444 if (err instanceof AuthRequiredError) { 445 throw err; 446 } 447 throw new AuthRequiredError("identity unknown"); 448 } 449 }; 450 const assertLxmCheck = () => { 451 const lxm = parseReqNsid(reqCtx.req); 452 if ( 453 (opts.lxmCheck && !opts.lxmCheck(payload.lxm)) || 454 (!opts.lxmCheck && payload.lxm !== lxm) 455 ) { 456 throw new AuthRequiredError( 457 payload.lxm !== undefined 458 ? `bad jwt lexicon method ("lxm"). must match: ${lxm}` 459 : `missing jwt lexicon method ("lxm"). must match: ${lxm}`, 460 "BadJwtLexiconMethod", 461 ); 462 } 463 }; 464 465 const jwtStr = bearerTokenFromReq(reqCtx.req); 466 if (!jwtStr) { 467 throw new AuthRequiredError("missing jwt", "MissingJwt"); 468 } 469 // if validating additional scopes, skip scope check in initial validation & follow up afterwards 470 const payload = await verifyJwt( 471 jwtStr, 472 opts.aud, 473 null, 474 getSigningKey, 475 ); 476 if ( 477 !payload.iss.endsWith("#atproto_labeler") || 478 payload.lxm !== undefined 479 ) { 480 // @TODO currently permissive of labelers who dont set lxm yet. 481 // we'll allow ozone self-hosters to upgrade before removing this condition. 482 assertLxmCheck(); 483 } 484 return { iss: payload.iss, aud: payload.aud }; 485 } 486 487 isModService(iss: string): boolean { 488 return [ 489 this.modServiceDid, 490 `${this.modServiceDid}#atproto_labeler`, 491 ].includes(iss); 492 } 493 494 nullCreds(): NullOutput { 495 return { 496 credentials: { 497 type: "none", 498 iss: null, 499 }, 500 artifacts: null, 501 }; 502 } 503 504 parseCreds( 505 auth: StandardOutput | RoleOutput | NullOutput | ModServiceOutput, 506 ) { 507 const creds = auth.credentials; 508 const includeTakedownsAnd3pBlocks = 509 (creds.type === "role" && creds.admin) || 510 creds.type === "mod_service" || 511 (creds.type === "standard" && 512 this.isModService(creds.iss)); 513 const canPerformTakedown = (creds.type === "role" && creds.admin) || 514 creds.type === "mod_service"; 515 return { 516 viewer: creds.type === "standard" ? creds.iss : null, 517 includeTakedowns: includeTakedownsAnd3pBlocks, 518 include3pBlocks: includeTakedownsAnd3pBlocks, 519 canPerformTakedown, 520 }; 521 } 522} 523 524// HELPERS 525// --------- 526 527const BEARER = "Bearer "; 528const BASIC = "Basic "; 529 530const isBearerToken = (req: Request): boolean => { 531 return req.headers.get("Authorization")?.startsWith(BEARER) ?? false; 532}; 533 534const isBasicToken = (req: Request): boolean => { 535 return req.headers.get("Authorization")?.startsWith(BASIC) ?? false; 536}; 537 538const bearerTokenFromReq = (req: Request) => { 539 const header = req.headers.get("Authorization") || ""; 540 if (!header.startsWith(BEARER)) return null; 541 return header.slice(BEARER.length).trim(); 542}; 543 544export const parseBasicAuth = ( 545 token: string, 546): { username: string; password: string } | null => { 547 if (!token.startsWith(BASIC)) return null; 548 const b64 = token.slice(BASIC.length); 549 let parsed: string[]; 550 try { 551 const binaryString = atob(b64); 552 const bytes = new Uint8Array(binaryString.length); 553 for (let i = 0; i < binaryString.length; i++) { 554 bytes[i] = binaryString.charCodeAt(i); 555 } 556 parsed = new TextDecoder("utf-8").decode(bytes).split(":"); 557 } catch { 558 return null; 559 } 560 const [username, password] = parsed; 561 if (!username || !password) return null; 562 return { username, password }; 563}; 564 565export const buildBasicAuth = (username: string, password: string): string => { 566 return ( 567 BASIC + 568 btoa( 569 new TextEncoder().encode(`${username}:${password}`).reduce( 570 (data, byte) => data + String.fromCharCode(byte), 571 "", 572 ), 573 ) 574 ); 575};