[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
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};