@atproto/lex-server 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +38 -21
  3. package/dist/errors.d.ts +28 -58
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/errors.js +72 -72
  6. package/dist/errors.js.map +1 -1
  7. package/dist/index.d.ts +1 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/{lex-server.d.ts → lex-router.d.ts} +55 -21
  12. package/dist/lex-router.d.ts.map +1 -0
  13. package/dist/{lex-server.js → lex-router.js} +169 -73
  14. package/dist/lex-router.js.map +1 -0
  15. package/dist/lib/drain-websocket.d.ts +7 -0
  16. package/dist/lib/drain-websocket.d.ts.map +1 -1
  17. package/dist/lib/drain-websocket.js +11 -0
  18. package/dist/lib/drain-websocket.js.map +1 -1
  19. package/dist/lib/www-authenticate.d.ts +4 -3
  20. package/dist/lib/www-authenticate.d.ts.map +1 -1
  21. package/dist/lib/www-authenticate.js +29 -16
  22. package/dist/lib/www-authenticate.js.map +1 -1
  23. package/dist/nodejs.d.ts +1 -1
  24. package/dist/nodejs.d.ts.map +1 -1
  25. package/dist/nodejs.js +1 -1
  26. package/dist/nodejs.js.map +1 -1
  27. package/dist/service-auth.d.ts +1 -1
  28. package/dist/service-auth.d.ts.map +1 -1
  29. package/dist/service-auth.js.map +1 -1
  30. package/package.json +9 -8
  31. package/src/errors.test.ts +262 -0
  32. package/src/errors.ts +103 -78
  33. package/src/index.ts +1 -7
  34. package/src/{lex-server.test.ts → lex-router.test.ts} +591 -24
  35. package/src/{lex-server.ts → lex-router.ts} +275 -119
  36. package/src/lib/drain-websocket.ts +11 -0
  37. package/src/lib/www-authenticate.test.ts +134 -0
  38. package/src/lib/www-authenticate.ts +36 -17
  39. package/src/nodejs.ts +2 -2
  40. package/src/service-auth.ts +1 -1
  41. package/dist/lex-server.d.ts.map +0 -1
  42. package/dist/lex-server.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"service-auth.js","sourceRoot":"","sources":["../src/service-auth.ts"],"names":[],"mappings":"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","sourcesContent":["import * as crypto from '@atproto/crypto'\nimport {\n AtprotoDid,\n AtprotoDidDocument,\n Did,\n matchesIdentifier,\n} from '@atproto/did'\nimport { fromBase64, isPlainObject, utf8FromBase64 } from '@atproto/lex-data'\nimport { DidString, isDidString } from '@atproto/lex-schema'\nimport {\n CreateDidResolverOptions,\n createDidResolver,\n} from '@atproto-labs/did-resolver'\nimport { LexServerAuthError } from './errors.js'\nimport { LexRouterAuth } from './lex-server.js'\n\nconst BEARER_PREFIX = 'Bearer '\n\n/**\n * Callback function to check and record nonce uniqueness.\n *\n * Used to prevent replay attacks by ensuring each nonce is only used once.\n * The implementation must track nonces for at least the `maxAge` duration\n * (default 5 minutes before and after the current time).\n *\n * @param nonce - The nonce string from the JWT token\n * @returns Promise resolving to `true` if the nonce is unique (first time seen),\n * `false` if it has been seen before\n *\n * @example\n * ```typescript\n * // Using Redis for nonce tracking\n * const checkNonce: UniqueNonceChecker = async (nonce) => {\n * const key = `nonce:${nonce}`\n * const result = await redis.setnx(key, '1')\n * if (result === 1) {\n * await redis.expire(key, 600) // 10 minutes TTL\n * return true\n * }\n * return false\n * }\n * ```\n */\nexport type UniqueNonceChecker = (nonce: string) => Promise<boolean>\n\n/**\n * Configuration options for AT Protocol service authentication.\n *\n * Service auth is used for server-to-server communication in the AT Protocol,\n * where one service authenticates to another using signed JWT tokens tied to\n * the caller's DID.\n *\n * @example\n * ```typescript\n * const options: ServiceAuthOptions = {\n * audience: 'did:web:api.example.com',\n * unique: async (nonce) => nonceStore.checkAndAdd(nonce),\n * maxAge: 300, // 5 minutes\n * // Optional DID resolver options\n * plcDirectoryUrl: 'https://plc.directory'\n * }\n * ```\n */\nexport type ServiceAuthOptions = CreateDidResolverOptions & {\n /**\n * Expected audience (\"aud\") claim in the JWT token.\n *\n * This should be the DID of your service. The token must include this\n * value in its `aud` claim to be accepted. Set to `null` to skip\n * audience verification (not recommended for production).\n */\n audience: null | DidString\n /**\n * Function to check and record nonce uniqueness.\n *\n * This is critical for preventing replay attacks. The value checked here\n * must be unique within `maxAge` seconds before and after the current time.\n *\n * @param nonce - The nonce to check\n * @returns Promise resolving to `true` if unique, `false` if seen before\n */\n unique: UniqueNonceChecker\n /**\n * Maximum age of the JWT token in seconds.\n *\n * Tokens with `iat` (issued at) or `exp` (expiry) timestamps outside\n * this window from the current time will be rejected.\n *\n * @default 300 (5 minutes)\n */\n maxAge?: number\n}\n\n/**\n * Credentials returned after successful service authentication.\n *\n * Contains the verified DID, resolved DID document, and parsed JWT token.\n * These are available in handler context as `ctx.credentials`.\n *\n * @example\n * ```typescript\n * router.add(protectedMethod, {\n * handler: async (ctx) => {\n * const { did, didDocument, jwt } = ctx.credentials\n * console.log('Request from:', did)\n * console.log('Token expires:', new Date(jwt.payload.exp * 1000))\n * return { body: { callerDid: did } }\n * },\n * auth: serviceAuth({ audience: myDid, unique: checkNonce })\n * })\n * ```\n */\nexport type ServiceAuthCredentials = {\n /** The verified AT Protocol DID of the caller. */\n did: AtprotoDid\n /** The resolved DID document of the caller. */\n didDocument: AtprotoDidDocument\n /** The parsed and validated JWT token. */\n jwt: ParsedJwt\n}\n\n/**\n * Creates an authentication handler for verifying AT Protocol service auth JWTs.\n *\n * Service auth is the standard authentication mechanism for server-to-server\n * communication in the AT Protocol. It uses JWT bearer tokens signed by the\n * caller's DID signing key, with the signature verified against the public\n * key in the caller's DID document.\n *\n * The handler performs the following validations:\n * - Extracts and parses the Bearer token from the Authorization header\n * - Validates JWT structure and claims (aud, exp, iat, lxm, nonce)\n * - Resolves the issuer's DID document\n * - Verifies the JWT signature against the `#atproto` verification method\n * - Checks nonce uniqueness to prevent replay attacks\n *\n * @param options - Configuration options for service auth\n * @returns An auth handler function for use with {@link LexRouter.add}\n *\n * @example Basic usage\n * ```typescript\n * import { LexRouter, serviceAuth } from '@atproto/lex-server'\n *\n * const router = new LexRouter()\n *\n * const auth = serviceAuth({\n * audience: 'did:web:api.example.com',\n * unique: async (nonce) => {\n * // Check if nonce has been seen, return true if unique\n * const isNew = await redis.setnx(`nonce:${nonce}`, '1')\n * if (isNew) await redis.expire(`nonce:${nonce}`, 600)\n * return isNew\n * }\n * })\n *\n * router.add(myMethod, {\n * handler: async (ctx) => {\n * console.log('Authenticated as:', ctx.credentials.did)\n * return { body: { success: true } }\n * },\n * auth\n * })\n * ```\n */\nexport function serviceAuth({\n audience,\n maxAge = 5 * 60,\n unique,\n ...options\n}: ServiceAuthOptions): LexRouterAuth<ServiceAuthCredentials> {\n const didResolver = createDidResolver(options)\n\n return async ({ request, method }) => {\n const { signal } = request\n const jwt = await parseJwtBearer(request, {\n lxm: method.nsid,\n maxAge,\n audience,\n unique,\n })\n\n let didDocument: AtprotoDidDocument = await didResolver\n .resolve(jwt.payload.iss, { signal })\n .catch((cause) => {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not resolve DID document',\n { Bearer: { error: 'DidResolutionFailed' } },\n { cause },\n )\n })\n\n const key = getAtprotoSigningKey(didDocument)\n\n if (!key || !(await verifyJwt(jwt, key))) {\n signal.throwIfAborted()\n\n // Try refreshing the DID document in case it was updated\n didDocument = await didResolver\n .resolve(jwt.payload.iss, { signal, noCache: true })\n .catch((cause) => {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not resolve DID document',\n { Bearer: { error: 'DidResolutionFailed' } },\n { cause },\n )\n })\n\n // Verify again with the fresh key (if it changed)\n const keyFresh = getAtprotoSigningKey(didDocument)\n if (!keyFresh || key === keyFresh || !(await verifyJwt(jwt, keyFresh))) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT signature',\n { Bearer: { error: 'BadJwtSignature' } },\n )\n }\n }\n\n return {\n did: didDocument.id,\n didDocument,\n jwt,\n }\n }\n}\n\nasync function verifyJwt(jwt: ParsedJwt, key: Did<'key'>) {\n try {\n return await crypto.verifySignature(key, jwt.message, jwt.signature, {\n jwtAlg: jwt.header.alg,\n allowMalleableSig: true,\n })\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not verify JWT signature',\n { Bearer: { error: 'BadJwtSignature' } },\n { cause },\n )\n }\n}\n\nfunction getAtprotoSigningKey(\n didDocument: AtprotoDidDocument,\n): null | Did<'key'> {\n try {\n const key = didDocument.verificationMethod?.find(\n isAtprotoVerificationMethod,\n didDocument,\n )\n\n if (key?.publicKeyMultibase) {\n if (key.type === 'EcdsaSecp256r1VerificationKey2019') {\n const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)\n return crypto.formatDidKey(crypto.P256_JWT_ALG, keyBytes)\n } else if (key.type === 'EcdsaSecp256k1VerificationKey2019') {\n const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)\n return crypto.formatDidKey(crypto.SECP256K1_JWT_ALG, keyBytes)\n } else if (key.type === 'Multikey') {\n const parsed = crypto.parseMultikey(key.publicKeyMultibase)\n return crypto.formatDidKey(parsed.jwtAlg, parsed.keyBytes)\n }\n }\n } catch {\n // Invalid key, ignore\n }\n\n return null\n}\n\nfunction isAtprotoVerificationMethod<\n V extends string | { id: string; type: string; publicKeyMultibase?: string },\n>(\n this: AtprotoDidDocument,\n vm: V,\n): vm is Exclude<V, string> & {\n id: `${string}#atproto`\n} {\n return typeof vm === 'object' && matchesIdentifier(this.id, 'atproto', vm.id)\n}\n\nasync function parseJwtBearer(\n request: Request,\n options: ParseJwtOptions,\n): Promise<ParsedJwt> {\n const authorization = request.headers.get('authorization')\n if (!authorization?.startsWith(BEARER_PREFIX)) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Bearer token required',\n { Bearer: { error: 'MissingBearer' } },\n )\n }\n\n const token = authorization.slice(BEARER_PREFIX.length).trim()\n\n return parseJwt(token, options)\n}\n\n/**\n * Options for parsing and validating a JWT token.\n */\nexport type ParseJwtOptions = {\n /** Maximum age in seconds for token validity window. */\n maxAge: number\n /** Expected audience claim, or null to skip audience verification. */\n audience: null | DidString\n /** Function to check nonce uniqueness. */\n unique: UniqueNonceChecker\n /** Expected lexicon method NSID for the `lxm` claim. */\n lxm: string\n}\n\n/**\n * A parsed and partially validated JWT token.\n *\n * Contains the decoded header and payload, along with the raw bytes\n * needed for signature verification.\n *\n * @example\n * ```typescript\n * const jwt: ParsedJwt = {\n * header: { alg: 'ES256K', typ: 'JWT' },\n * payload: {\n * iss: 'did:plc:abc123',\n * aud: 'did:web:api.example.com',\n * exp: 1704067200,\n * iat: 1704066900,\n * lxm: 'com.atproto.sync.getBlob'\n * },\n * message: new Uint8Array([...]),\n * signature: new Uint8Array([...])\n * }\n * ```\n */\nexport type ParsedJwt = {\n /** The decoded JWT header containing algorithm and type. */\n header: HeaderObject\n /** The decoded JWT payload containing claims. */\n payload: PayloadObject\n /** The raw header.payload bytes for signature verification. */\n message: Uint8Array\n /** The decoded signature bytes. */\n signature: Uint8Array\n}\n\nasync function parseJwt(\n token: string,\n options: ParseJwtOptions,\n): Promise<ParsedJwt> {\n const {\n length,\n 0: headerB64,\n 1: payloadB64,\n 2: signatureB64,\n } = token.split('.')\n if (length !== 3) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n )\n }\n\n let header: HeaderObject\n try {\n header = jsonFromBase64(headerB64, isHeaderObject)\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n { cause },\n )\n }\n\n if (\n header.alg === 'none' ||\n // service tokens are not OAuth 2.0 access tokens\n // https://datatracker.ietf.org/doc/html/rfc9068\n header.typ === 'at+jwt' ||\n // \"refresh+jwt\" is a non-standard type used by the @atproto packages\n header.typ === 'refresh+jwt' ||\n // \"DPoP\" proofs are not meant to be used as service tokens\n // https://datatracker.ietf.org/doc/html/rfc9449\n header.typ === 'dpop+jwt'\n ) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n )\n }\n\n let payload: PayloadObject\n try {\n payload = jsonFromBase64(payloadB64, isPayloadObject)\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n { cause },\n )\n }\n\n if (options.audience !== null && options.audience !== payload.aud) {\n throw new LexServerAuthError('AuthenticationRequired', 'Invalid audience', {\n Bearer: { error: 'InvalidAudience' },\n })\n }\n\n const now = Math.floor(Date.now() / 1000)\n\n if (payload.nbf != null && now < payload.nbf) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token not yet valid',\n { Bearer: { error: 'JwtNotYetValid' } },\n )\n }\n\n if (now > payload.exp) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token expired',\n { Bearer: { error: 'JwtExpired' } },\n )\n }\n\n // Prevent issuer from generating very long-lived tokens\n if (\n timeDiff(now, payload.exp) > options.maxAge ||\n timeDiff(now, payload.iat) > options.maxAge\n ) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token too old',\n { Bearer: { error: 'JwtTooOld' } },\n )\n }\n\n if (payload.lxm != null && typeof payload.lxm !== options.lxm) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT lexicon method (\"lxm\")',\n { Bearer: { error: 'BadJwtLexiconMethod' } },\n )\n }\n\n if (payload.nonce != null && !(await (0, options.unique)(payload.nonce))) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Replay attack detected: nonce is not unique',\n { Bearer: { error: 'NonceNotUnique' } },\n )\n }\n\n return {\n header,\n payload,\n message: textEncoder.encode(`${headerB64}.${payloadB64}`),\n signature: fromBase64(signatureB64, 'base64url'),\n }\n}\n\nconst textEncoder = /*#__PURE__*/ new TextEncoder()\n\ntype HeaderObject = { alg: string; typ?: string }\nfunction isHeaderObject(obj: unknown): obj is HeaderObject {\n return (\n isPlainObject(obj) &&\n typeof obj.alg === 'string' &&\n (obj.typ === undefined || typeof obj.typ === 'string')\n )\n}\n\ntype PayloadObject = {\n iss: DidString\n aud: DidString\n exp: number\n iat?: number\n nbf?: number\n lxm?: string\n nonce?: string\n}\nexport function isPayloadObject(obj: unknown): obj is PayloadObject {\n return (\n isPlainObject(obj) &&\n typeof obj.iss === 'string' &&\n typeof obj.aud === 'string' &&\n (obj.lxm === undefined || typeof obj.lxm === 'string') &&\n (obj.nonce === undefined || typeof obj.nonce === 'string') &&\n (obj.iat === undefined || isPositiveInt(obj.iat)) &&\n (obj.nbf === undefined || isPositiveInt(obj.nbf)) &&\n isPositiveInt(obj.exp) &&\n isDidString(obj.iss) &&\n isDidString(obj.aud)\n )\n}\n\nfunction timeDiff(t1: number, t2?: number): number {\n if (t2 === undefined) return 0\n return Math.abs(t1 - t2)\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction jsonFromBase64<T>(b64: string, isType: (obj: unknown) => obj is T): T {\n const obj = JSON.parse(utf8FromBase64(b64, 'base64url'))\n if (isType(obj)) return obj\n throw new Error('Invalid type')\n}\n"]}
1
+ {"version":3,"file":"service-auth.js","sourceRoot":"","sources":["../src/service-auth.ts"],"names":[],"mappings":"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","sourcesContent":["import * as crypto from '@atproto/crypto'\nimport {\n AtprotoDid,\n AtprotoDidDocument,\n Did,\n matchesIdentifier,\n} from '@atproto/did'\nimport { fromBase64, isPlainObject, utf8FromBase64 } from '@atproto/lex-data'\nimport { DidString, isDidString } from '@atproto/lex-schema'\nimport {\n CreateDidResolverOptions,\n createDidResolver,\n} from '@atproto-labs/did-resolver'\nimport { LexServerAuthError } from './errors.js'\nimport type { LexRouterAuth } from './lex-router.js'\n\nconst BEARER_PREFIX = 'Bearer '\n\n/**\n * Callback function to check and record nonce uniqueness.\n *\n * Used to prevent replay attacks by ensuring each nonce is only used once.\n * The implementation must track nonces for at least the `maxAge` duration\n * (default 5 minutes before and after the current time).\n *\n * @param nonce - The nonce string from the JWT token\n * @returns Promise resolving to `true` if the nonce is unique (first time seen),\n * `false` if it has been seen before\n *\n * @example\n * ```typescript\n * // Using Redis for nonce tracking\n * const checkNonce: UniqueNonceChecker = async (nonce) => {\n * const key = `nonce:${nonce}`\n * const result = await redis.setnx(key, '1')\n * if (result === 1) {\n * await redis.expire(key, 600) // 10 minutes TTL\n * return true\n * }\n * return false\n * }\n * ```\n */\nexport type UniqueNonceChecker = (nonce: string) => Promise<boolean>\n\n/**\n * Configuration options for AT Protocol service authentication.\n *\n * Service auth is used for server-to-server communication in the AT Protocol,\n * where one service authenticates to another using signed JWT tokens tied to\n * the caller's DID.\n *\n * @example\n * ```typescript\n * const options: ServiceAuthOptions = {\n * audience: 'did:web:api.example.com',\n * unique: async (nonce) => nonceStore.checkAndAdd(nonce),\n * maxAge: 300, // 5 minutes\n * // Optional DID resolver options\n * plcDirectoryUrl: 'https://plc.directory'\n * }\n * ```\n */\nexport type ServiceAuthOptions = CreateDidResolverOptions & {\n /**\n * Expected audience (\"aud\") claim in the JWT token.\n *\n * This should be the DID of your service. The token must include this\n * value in its `aud` claim to be accepted. Set to `null` to skip\n * audience verification (not recommended for production).\n */\n audience: null | DidString\n /**\n * Function to check and record nonce uniqueness.\n *\n * This is critical for preventing replay attacks. The value checked here\n * must be unique within `maxAge` seconds before and after the current time.\n *\n * @param nonce - The nonce to check\n * @returns Promise resolving to `true` if unique, `false` if seen before\n */\n unique: UniqueNonceChecker\n /**\n * Maximum age of the JWT token in seconds.\n *\n * Tokens with `iat` (issued at) or `exp` (expiry) timestamps outside\n * this window from the current time will be rejected.\n *\n * @default 300 (5 minutes)\n */\n maxAge?: number\n}\n\n/**\n * Credentials returned after successful service authentication.\n *\n * Contains the verified DID, resolved DID document, and parsed JWT token.\n * These are available in handler context as `ctx.credentials`.\n *\n * @example\n * ```typescript\n * router.add(protectedMethod, {\n * handler: async (ctx) => {\n * const { did, didDocument, jwt } = ctx.credentials\n * console.log('Request from:', did)\n * console.log('Token expires:', new Date(jwt.payload.exp * 1000))\n * return { body: { callerDid: did } }\n * },\n * auth: serviceAuth({ audience: myDid, unique: checkNonce })\n * })\n * ```\n */\nexport type ServiceAuthCredentials = {\n /** The verified AT Protocol DID of the caller. */\n did: AtprotoDid\n /** The resolved DID document of the caller. */\n didDocument: AtprotoDidDocument\n /** The parsed and validated JWT token. */\n jwt: ParsedJwt\n}\n\n/**\n * Creates an authentication handler for verifying AT Protocol service auth JWTs.\n *\n * Service auth is the standard authentication mechanism for server-to-server\n * communication in the AT Protocol. It uses JWT bearer tokens signed by the\n * caller's DID signing key, with the signature verified against the public\n * key in the caller's DID document.\n *\n * The handler performs the following validations:\n * - Extracts and parses the Bearer token from the Authorization header\n * - Validates JWT structure and claims (aud, exp, iat, lxm, nonce)\n * - Resolves the issuer's DID document\n * - Verifies the JWT signature against the `#atproto` verification method\n * - Checks nonce uniqueness to prevent replay attacks\n *\n * @param options - Configuration options for service auth\n * @returns An auth handler function for use with {@link LexRouter.add}\n *\n * @example Basic usage\n * ```typescript\n * import { LexRouter, serviceAuth } from '@atproto/lex-server'\n *\n * const router = new LexRouter()\n *\n * const auth = serviceAuth({\n * audience: 'did:web:api.example.com',\n * unique: async (nonce) => {\n * // Check if nonce has been seen, return true if unique\n * const isNew = await redis.setnx(`nonce:${nonce}`, '1')\n * if (isNew) await redis.expire(`nonce:${nonce}`, 600)\n * return isNew\n * }\n * })\n *\n * router.add(myMethod, {\n * handler: async (ctx) => {\n * console.log('Authenticated as:', ctx.credentials.did)\n * return { body: { success: true } }\n * },\n * auth\n * })\n * ```\n */\nexport function serviceAuth({\n audience,\n maxAge = 5 * 60,\n unique,\n ...options\n}: ServiceAuthOptions): LexRouterAuth<ServiceAuthCredentials> {\n const didResolver = createDidResolver(options)\n\n return async ({ request, method }) => {\n const { signal } = request\n const jwt = await parseJwtBearer(request, {\n lxm: method.nsid,\n maxAge,\n audience,\n unique,\n })\n\n let didDocument: AtprotoDidDocument = await didResolver\n .resolve(jwt.payload.iss, { signal })\n .catch((cause) => {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not resolve DID document',\n { Bearer: { error: 'DidResolutionFailed' } },\n { cause },\n )\n })\n\n const key = getAtprotoSigningKey(didDocument)\n\n if (!key || !(await verifyJwt(jwt, key))) {\n signal.throwIfAborted()\n\n // Try refreshing the DID document in case it was updated\n didDocument = await didResolver\n .resolve(jwt.payload.iss, { signal, noCache: true })\n .catch((cause) => {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not resolve DID document',\n { Bearer: { error: 'DidResolutionFailed' } },\n { cause },\n )\n })\n\n // Verify again with the fresh key (if it changed)\n const keyFresh = getAtprotoSigningKey(didDocument)\n if (!keyFresh || key === keyFresh || !(await verifyJwt(jwt, keyFresh))) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT signature',\n { Bearer: { error: 'BadJwtSignature' } },\n )\n }\n }\n\n return {\n did: didDocument.id,\n didDocument,\n jwt,\n }\n }\n}\n\nasync function verifyJwt(jwt: ParsedJwt, key: Did<'key'>) {\n try {\n return await crypto.verifySignature(key, jwt.message, jwt.signature, {\n jwtAlg: jwt.header.alg,\n allowMalleableSig: true,\n })\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Could not verify JWT signature',\n { Bearer: { error: 'BadJwtSignature' } },\n { cause },\n )\n }\n}\n\nfunction getAtprotoSigningKey(\n didDocument: AtprotoDidDocument,\n): null | Did<'key'> {\n try {\n const key = didDocument.verificationMethod?.find(\n isAtprotoVerificationMethod,\n didDocument,\n )\n\n if (key?.publicKeyMultibase) {\n if (key.type === 'EcdsaSecp256r1VerificationKey2019') {\n const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)\n return crypto.formatDidKey(crypto.P256_JWT_ALG, keyBytes)\n } else if (key.type === 'EcdsaSecp256k1VerificationKey2019') {\n const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)\n return crypto.formatDidKey(crypto.SECP256K1_JWT_ALG, keyBytes)\n } else if (key.type === 'Multikey') {\n const parsed = crypto.parseMultikey(key.publicKeyMultibase)\n return crypto.formatDidKey(parsed.jwtAlg, parsed.keyBytes)\n }\n }\n } catch {\n // Invalid key, ignore\n }\n\n return null\n}\n\nfunction isAtprotoVerificationMethod<\n V extends string | { id: string; type: string; publicKeyMultibase?: string },\n>(\n this: AtprotoDidDocument,\n vm: V,\n): vm is Exclude<V, string> & {\n id: `${string}#atproto`\n} {\n return typeof vm === 'object' && matchesIdentifier(this.id, 'atproto', vm.id)\n}\n\nasync function parseJwtBearer(\n request: Request,\n options: ParseJwtOptions,\n): Promise<ParsedJwt> {\n const authorization = request.headers.get('authorization')\n if (!authorization?.startsWith(BEARER_PREFIX)) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Bearer token required',\n { Bearer: { error: 'MissingBearer' } },\n )\n }\n\n const token = authorization.slice(BEARER_PREFIX.length).trim()\n\n return parseJwt(token, options)\n}\n\n/**\n * Options for parsing and validating a JWT token.\n */\nexport type ParseJwtOptions = {\n /** Maximum age in seconds for token validity window. */\n maxAge: number\n /** Expected audience claim, or null to skip audience verification. */\n audience: null | DidString\n /** Function to check nonce uniqueness. */\n unique: UniqueNonceChecker\n /** Expected lexicon method NSID for the `lxm` claim. */\n lxm: string\n}\n\n/**\n * A parsed and partially validated JWT token.\n *\n * Contains the decoded header and payload, along with the raw bytes\n * needed for signature verification.\n *\n * @example\n * ```typescript\n * const jwt: ParsedJwt = {\n * header: { alg: 'ES256K', typ: 'JWT' },\n * payload: {\n * iss: 'did:plc:abc123',\n * aud: 'did:web:api.example.com',\n * exp: 1704067200,\n * iat: 1704066900,\n * lxm: 'com.atproto.sync.getBlob'\n * },\n * message: new Uint8Array([...]),\n * signature: new Uint8Array([...])\n * }\n * ```\n */\nexport type ParsedJwt = {\n /** The decoded JWT header containing algorithm and type. */\n header: HeaderObject\n /** The decoded JWT payload containing claims. */\n payload: PayloadObject\n /** The raw header.payload bytes for signature verification. */\n message: Uint8Array\n /** The decoded signature bytes. */\n signature: Uint8Array\n}\n\nasync function parseJwt(\n token: string,\n options: ParseJwtOptions,\n): Promise<ParsedJwt> {\n const {\n length,\n 0: headerB64,\n 1: payloadB64,\n 2: signatureB64,\n } = token.split('.')\n if (length !== 3) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n )\n }\n\n let header: HeaderObject\n try {\n header = jsonFromBase64(headerB64, isHeaderObject)\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n { cause },\n )\n }\n\n if (\n header.alg === 'none' ||\n // service tokens are not OAuth 2.0 access tokens\n // https://datatracker.ietf.org/doc/html/rfc9068\n header.typ === 'at+jwt' ||\n // \"refresh+jwt\" is a non-standard type used by the @atproto packages\n header.typ === 'refresh+jwt' ||\n // \"DPoP\" proofs are not meant to be used as service tokens\n // https://datatracker.ietf.org/doc/html/rfc9449\n header.typ === 'dpop+jwt'\n ) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n )\n }\n\n let payload: PayloadObject\n try {\n payload = jsonFromBase64(payloadB64, isPayloadObject)\n } catch (cause) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT token',\n { Bearer: { error: 'BadJwt' } },\n { cause },\n )\n }\n\n if (options.audience !== null && options.audience !== payload.aud) {\n throw new LexServerAuthError('AuthenticationRequired', 'Invalid audience', {\n Bearer: { error: 'InvalidAudience' },\n })\n }\n\n const now = Math.floor(Date.now() / 1000)\n\n if (payload.nbf != null && now < payload.nbf) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token not yet valid',\n { Bearer: { error: 'JwtNotYetValid' } },\n )\n }\n\n if (now > payload.exp) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token expired',\n { Bearer: { error: 'JwtExpired' } },\n )\n }\n\n // Prevent issuer from generating very long-lived tokens\n if (\n timeDiff(now, payload.exp) > options.maxAge ||\n timeDiff(now, payload.iat) > options.maxAge\n ) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'JWT token too old',\n { Bearer: { error: 'JwtTooOld' } },\n )\n }\n\n if (payload.lxm != null && typeof payload.lxm !== options.lxm) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Invalid JWT lexicon method (\"lxm\")',\n { Bearer: { error: 'BadJwtLexiconMethod' } },\n )\n }\n\n if (payload.nonce != null && !(await (0, options.unique)(payload.nonce))) {\n throw new LexServerAuthError(\n 'AuthenticationRequired',\n 'Replay attack detected: nonce is not unique',\n { Bearer: { error: 'NonceNotUnique' } },\n )\n }\n\n return {\n header,\n payload,\n message: textEncoder.encode(`${headerB64}.${payloadB64}`),\n signature: fromBase64(signatureB64, 'base64url'),\n }\n}\n\nconst textEncoder = /*#__PURE__*/ new TextEncoder()\n\ntype HeaderObject = { alg: string; typ?: string }\nfunction isHeaderObject(obj: unknown): obj is HeaderObject {\n return (\n isPlainObject(obj) &&\n typeof obj.alg === 'string' &&\n (obj.typ === undefined || typeof obj.typ === 'string')\n )\n}\n\ntype PayloadObject = {\n iss: DidString\n aud: DidString\n exp: number\n iat?: number\n nbf?: number\n lxm?: string\n nonce?: string\n}\nexport function isPayloadObject(obj: unknown): obj is PayloadObject {\n return (\n isPlainObject(obj) &&\n typeof obj.iss === 'string' &&\n typeof obj.aud === 'string' &&\n (obj.lxm === undefined || typeof obj.lxm === 'string') &&\n (obj.nonce === undefined || typeof obj.nonce === 'string') &&\n (obj.iat === undefined || isPositiveInt(obj.iat)) &&\n (obj.nbf === undefined || isPositiveInt(obj.nbf)) &&\n isPositiveInt(obj.exp) &&\n isDidString(obj.iss) &&\n isDidString(obj.aud)\n )\n}\n\nfunction timeDiff(t1: number, t2?: number): number {\n if (t2 === undefined) return 0\n return Math.abs(t1 - t2)\n}\n\nfunction isPositiveInt(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n}\n\nfunction jsonFromBase64<T>(b64: string, isType: (obj: unknown) => obj is T): T {\n const obj = JSON.parse(utf8FromBase64(b64, 'base64url'))\n if (isType(obj)) return obj\n throw new Error('Invalid type')\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/lex-server",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "description": "Request router for Atproto Lexicon protocols and schemas",
6
6
  "keywords": [
@@ -46,19 +46,20 @@
46
46
  "tslib": "^2.8.1",
47
47
  "ws": "^8.18.3",
48
48
  "@atproto-labs/did-resolver": "^0.2.6",
49
- "@atproto/crypto": "^0.4.5",
50
49
  "@atproto/did": "^0.3.0",
51
- "@atproto/lex-cbor": "^0.0.13",
52
- "@atproto/lex-data": "^0.0.12",
53
- "@atproto/lex-json": "^0.0.12",
54
- "@atproto/lex-schema": "^0.0.13"
50
+ "@atproto/lex-cbor": "^0.0.14",
51
+ "@atproto/lex-client": "^0.0.16",
52
+ "@atproto/lex-data": "^0.0.13",
53
+ "@atproto/lex-json": "^0.0.13",
54
+ "@atproto/lex-schema": "^0.0.15",
55
+ "@atproto/crypto": "^0.4.5"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/ws": "^8.18.1",
58
59
  "@vitest/coverage-v8": "4.0.16",
59
60
  "vitest": "^4.0.16",
60
- "@atproto/lex": "^0.0.18",
61
- "@atproto/lex-client": "^0.0.13"
61
+ "@atproto/lex-client": "^0.0.16",
62
+ "@atproto/lex": "^0.0.21"
62
63
  },
63
64
  "scripts": {
64
65
  "build": "tsc --build tsconfig.build.json",
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { XrpcInternalError, XrpcResponseError } from '@atproto/lex-client'
3
+ import { LexError } from '@atproto/lex-data'
4
+ import { IssueInvalidType, LexValidationError, l } from '@atproto/lex-schema'
5
+ import { LexServerAuthError, LexServerError } from './errors.js'
6
+
7
+ // Minimal method fixtures for XrpcError subclasses
8
+ const testQuery = l.query(
9
+ 'io.example.test',
10
+ l.params(),
11
+ l.payload('application/json', l.object({ value: l.string() })),
12
+ )
13
+
14
+ describe(LexServerError, () => {
15
+ it('stores status, body, and headers', () => {
16
+ const error = new LexServerError(
17
+ 400,
18
+ { error: 'InvalidRequest', message: 'Bad input' },
19
+ { 'X-Custom': 'header' },
20
+ )
21
+
22
+ expect(error.status).toBe(400)
23
+ expect(error.body).toEqual({
24
+ error: 'InvalidRequest',
25
+ message: 'Bad input',
26
+ })
27
+ expect(error.headers?.get('x-custom')).toBe('header')
28
+ expect(error.error).toBe('InvalidRequest')
29
+ expect(error.message).toBe('Bad input')
30
+ })
31
+
32
+ it('has undefined headers when none provided', () => {
33
+ const error = new LexServerError(500, { error: 'InternalError' })
34
+ expect(error.headers).toBeUndefined()
35
+ })
36
+
37
+ it('toJSON returns the body', () => {
38
+ const body = { error: 'TestError' as const, message: 'test' }
39
+ const error = new LexServerError(400, body)
40
+ expect(error.toJSON()).toEqual(body)
41
+ })
42
+
43
+ it('toResponse creates a Response with correct status and body', async () => {
44
+ const error = new LexServerError(
45
+ 422,
46
+ { error: 'ValidationError', message: 'Invalid data' },
47
+ { 'X-Test': 'yes' },
48
+ )
49
+ const response = error.toResponse()
50
+
51
+ expect(response.status).toBe(422)
52
+ expect(response.headers.get('X-Test')).toBe('yes')
53
+ expect(await response.json()).toEqual({
54
+ error: 'ValidationError',
55
+ message: 'Invalid data',
56
+ })
57
+ })
58
+
59
+ describe('from()', () => {
60
+ it('returns existing LexServerError as-is', () => {
61
+ const original = new LexServerError(400, { error: 'Test' })
62
+ expect(LexServerError.from(original)).toBe(original)
63
+ })
64
+
65
+ it('returns a LexServerAuthError as-is (since it extends LexServerError)', () => {
66
+ const original = new LexServerAuthError('AuthenticationRequired', 'test')
67
+ expect(LexServerError.from(original)).toBe(original)
68
+ })
69
+
70
+ it('converts XrpcError to downstream LexServerError', () => {
71
+ const xrpcError = new XrpcInternalError(testQuery, 'Something broke')
72
+ const serverError = LexServerError.from(xrpcError)
73
+
74
+ expect(serverError).toBeInstanceOf(LexServerError)
75
+ expect(serverError.status).toBe(500)
76
+ expect(serverError.body.error).toBe('InternalServerError')
77
+ expect(serverError.cause).toBe(xrpcError)
78
+ })
79
+
80
+ it('converts XrpcResponseError with 500 to 502', () => {
81
+ const response = new Response(null, { status: 500 })
82
+ const xrpcError = new XrpcResponseError(testQuery, response, {
83
+ encoding: 'application/json',
84
+ body: { error: 'Boom', message: 'Try again later' },
85
+ })
86
+ const serverError = LexServerError.from(xrpcError)
87
+
88
+ expect(serverError.status).toBe(502)
89
+ expect(serverError.body.error).toBe('Boom')
90
+ })
91
+
92
+ it('preserves the status of non-500 5xx errors', () => {
93
+ const response = new Response(null, { status: 502 })
94
+ const xrpcError = new XrpcResponseError(testQuery, response, {
95
+ encoding: 'application/json',
96
+ body: { error: 'FooBar', message: 'Try again later' },
97
+ })
98
+ const serverError = LexServerError.from(xrpcError)
99
+
100
+ expect(serverError.status).toBe(502)
101
+ expect(serverError.body.error).toBe('FooBar')
102
+ })
103
+
104
+ it('converts XrpcResponseError with 4xx preserving status', () => {
105
+ const response = new Response(null, { status: 404 })
106
+ const xrpcError = new XrpcResponseError(testQuery, response, {
107
+ encoding: 'application/json',
108
+ body: { error: 'NotFound', message: 'Record not found' },
109
+ })
110
+ const serverError = LexServerError.from(xrpcError)
111
+
112
+ expect(serverError.status).toBe(404)
113
+ expect(serverError.body.error).toBe('NotFound')
114
+ })
115
+
116
+ it('converts LexValidationError to 400', () => {
117
+ const validationError = new LexValidationError([
118
+ new IssueInvalidType([], 'hello', ['number']),
119
+ ])
120
+ const serverError = LexServerError.from(validationError)
121
+
122
+ expect(serverError.status).toBe(400)
123
+ expect(serverError.body.error).toBe('InvalidRequest')
124
+ expect(serverError.cause).toBe(validationError)
125
+ })
126
+
127
+ it('converts plain LexError to 500', () => {
128
+ const lexError = new LexError('CustomError', 'Something happened')
129
+ const serverError = LexServerError.from(lexError)
130
+
131
+ expect(serverError.status).toBe(500)
132
+ expect(serverError.body.error).toBe('CustomError')
133
+ expect(serverError.cause).toBe(lexError)
134
+ })
135
+
136
+ it('converts unknown errors to 500 InternalServerError', () => {
137
+ const serverError = LexServerError.from(new TypeError('oops'))
138
+
139
+ expect(serverError.status).toBe(500)
140
+ expect(serverError.body.error).toBe('InternalServerError')
141
+ expect(serverError.body.message).toBe('An internal error occurred')
142
+ })
143
+
144
+ it('converts non-Error values to 500 InternalServerError', () => {
145
+ const serverError = LexServerError.from('string error')
146
+
147
+ expect(serverError.status).toBe(500)
148
+ expect(serverError.body.error).toBe('InternalServerError')
149
+ })
150
+ })
151
+ })
152
+
153
+ describe(LexServerAuthError, () => {
154
+ it('always has status 401', () => {
155
+ const error = new LexServerAuthError(
156
+ 'AuthenticationRequired',
157
+ 'Token expired',
158
+ )
159
+ expect(error.status).toBe(401)
160
+ })
161
+
162
+ it('sets WWW-Authenticate header', () => {
163
+ const error = new LexServerAuthError(
164
+ 'AuthenticationRequired',
165
+ 'Token required',
166
+ { Bearer: { realm: 'api.example.com', error: 'InvalidToken' } },
167
+ )
168
+ const header = error.headers?.get('WWW-Authenticate')
169
+ expect(header).toContain('Bearer')
170
+ expect(header).toContain('realm="api.example.com"')
171
+ expect(header).toContain('error="InvalidToken"')
172
+ })
173
+
174
+ it('sets Access-Control-Expose-Headers for CORS', () => {
175
+ const error = new LexServerAuthError(
176
+ 'AuthenticationRequired',
177
+ 'Token required',
178
+ {
179
+ Bearer: { realm: 'api.example.com', error: 'InvalidToken' },
180
+ },
181
+ )
182
+ expect(error.headers?.get('Access-Control-Expose-Headers')).toBe(
183
+ 'WWW-Authenticate',
184
+ )
185
+ expect(error.headers?.get('WWW-Authenticate')).toBe(
186
+ 'Bearer realm="api.example.com", error="InvalidToken"',
187
+ )
188
+ })
189
+
190
+ it('does not set WWW-Authenticate header if wwwAuthenticate is empty', () => {
191
+ const error = new LexServerAuthError('AuthenticationRequired', 'No token')
192
+ expect(error.headers).toBeUndefined()
193
+ })
194
+
195
+ it('toResponse returns 401 with proper headers', async () => {
196
+ const error = new LexServerAuthError(
197
+ 'AuthenticationRequired',
198
+ 'Missing token',
199
+ { Bearer: { error: 'MissingToken' } },
200
+ )
201
+ const response = error.toResponse()
202
+
203
+ expect(response.status).toBe(401)
204
+ expect(response.headers.get('WWW-Authenticate')).toBe(
205
+ 'Bearer error="MissingToken"',
206
+ )
207
+ const body = await response.json()
208
+ expect(body.error).toBe('AuthenticationRequired')
209
+ expect(body.message).toBe('Missing token')
210
+ })
211
+
212
+ describe('from()', () => {
213
+ it('returns existing LexServerAuthError as-is', () => {
214
+ const original = new LexServerAuthError('AuthenticationRequired', 'test')
215
+ expect(LexServerAuthError.from(original)).toBe(original)
216
+ })
217
+
218
+ it('wraps a LexServerError using its error code and message', () => {
219
+ const serverError = new LexServerError(403, {
220
+ error: 'Forbidden',
221
+ message: 'Access denied',
222
+ })
223
+ const authError = LexServerAuthError.from(serverError, {
224
+ Bearer: { error: 'InsufficientScope' },
225
+ })
226
+
227
+ expect(authError).toBeInstanceOf(LexServerAuthError)
228
+ expect(authError.error).toBe('Forbidden')
229
+ expect(authError.message).toBe('Access denied')
230
+ expect(authError.cause).toBe(serverError)
231
+ expect(authError.headers?.get('WWW-Authenticate')).toBe(
232
+ 'Bearer error="InsufficientScope"',
233
+ )
234
+ })
235
+
236
+ it('wraps a LexError preserving error code and message', () => {
237
+ const lexError = new LexError('ExpiredToken', 'Token has expired')
238
+ const authError = LexServerAuthError.from(lexError, {
239
+ Bearer: { error: 'ExpiredToken' },
240
+ })
241
+
242
+ expect(authError).toBeInstanceOf(LexServerAuthError)
243
+ expect(authError.error).toBe('ExpiredToken')
244
+ expect(authError.message).toBe('Token has expired')
245
+ expect(authError.cause).toBe(lexError)
246
+ })
247
+
248
+ it('wraps unknown errors with default error code', () => {
249
+ const authError = LexServerAuthError.from(new Error('something'))
250
+
251
+ expect(authError.error).toBe('AuthenticationRequired')
252
+ expect(authError.message).toBe('Authentication failed')
253
+ })
254
+
255
+ it('wraps non-Error values with default error code', () => {
256
+ const authError = LexServerAuthError.from(null)
257
+
258
+ expect(authError.error).toBe('AuthenticationRequired')
259
+ expect(authError.message).toBe('Authentication failed')
260
+ })
261
+ })
262
+ })
package/src/errors.ts CHANGED
@@ -1,10 +1,78 @@
1
- import { LexError, LexErrorCode } from '@atproto/lex-data'
1
+ import { XrpcError } from '@atproto/lex-client'
2
+ import { LexError, LexErrorCode, LexErrorData } from '@atproto/lex-data'
3
+ import { LexValidationError } from '@atproto/lex-schema'
2
4
  import {
3
5
  WWWAuthenticate,
4
6
  formatWWWAuthenticateHeader,
5
7
  } from './lib/www-authenticate.js'
6
8
 
7
- export type { WWWAuthenticate }
9
+ export { LexError }
10
+ export type { LexErrorCode, LexErrorData, WWWAuthenticate }
11
+
12
+ /**
13
+ * Base error class for representing errors that should be converted to XRPC
14
+ * error responses.
15
+ */
16
+ export class LexServerError<
17
+ N extends LexErrorCode = LexErrorCode,
18
+ > extends LexError<N> {
19
+ name = 'LexServerError'
20
+
21
+ readonly headers?: Headers
22
+
23
+ constructor(
24
+ readonly status: number,
25
+ readonly body: LexErrorData<N>,
26
+ headers?: HeadersInit,
27
+ options?: ErrorOptions,
28
+ ) {
29
+ super(body.error, body.message, options)
30
+ this.headers = headers ? new Headers(headers) : undefined
31
+ }
32
+
33
+ override toJSON(): LexErrorData<N> {
34
+ return this.body
35
+ }
36
+
37
+ public toResponse(): Response {
38
+ const { status, headers } = this
39
+ // @NOTE using this.toJSON() instead of this.body to allow overrides in subclasses
40
+ return Response.json(this.toJSON(), { status, headers })
41
+ }
42
+
43
+ static from(cause: unknown): LexServerError {
44
+ if (cause instanceof LexServerError) {
45
+ return cause
46
+ }
47
+
48
+ // Convert @atproto/lex-client errors to downstream LexServerError
49
+ if (cause instanceof XrpcError) {
50
+ const { status, body, headers } = cause.toDownstreamError()
51
+ return new LexServerError(status, body, headers, { cause })
52
+ }
53
+
54
+ // Convert @atproto/lex-schema validation errors to 400 Bad Request
55
+ if (cause instanceof LexValidationError) {
56
+ return new LexServerError(400, cause.toJSON(), undefined, {
57
+ cause,
58
+ })
59
+ }
60
+
61
+ // Any other error is treated as a generic 500 Internal Server Error
62
+ if (cause instanceof LexError) {
63
+ return new LexServerError(500, cause.toJSON(), undefined, {
64
+ cause,
65
+ })
66
+ }
67
+
68
+ return new LexServerError(
69
+ 500,
70
+ { error: 'InternalServerError', message: 'An internal error occurred' },
71
+ undefined,
72
+ { cause },
73
+ )
74
+ }
75
+ }
8
76
 
9
77
  /**
10
78
  * Error class for authentication failures in XRPC server handlers.
@@ -26,24 +94,10 @@ export type { WWWAuthenticate }
26
94
  * { Bearer: { error: 'InvalidToken', realm: 'api.example.com' } }
27
95
  * )
28
96
  * ```
29
- *
30
- * @example Converting from a LexError
31
- * ```typescript
32
- * try {
33
- * await validateToken(token)
34
- * } catch (error) {
35
- * if (error instanceof LexError) {
36
- * throw LexServerAuthError.from(error, {
37
- * Bearer: { error: 'InvalidToken' }
38
- * })
39
- * }
40
- * throw error
41
- * }
42
- * ```
43
97
  */
44
98
  export class LexServerAuthError<
45
99
  N extends LexErrorCode = LexErrorCode,
46
- > extends LexError<N> {
100
+ > extends LexServerError<N> {
47
101
  name = 'LexServerAuthError'
48
102
 
49
103
  /**
@@ -60,62 +114,13 @@ export class LexServerAuthError<
60
114
  readonly wwwAuthenticate: WWWAuthenticate = {},
61
115
  options?: ErrorOptions,
62
116
  ) {
63
- super(error, message, options)
64
- }
65
-
66
- /**
67
- * Gets the formatted WWW-Authenticate header value.
68
- *
69
- * @returns The formatted header string for the 401 response
70
- *
71
- * @example
72
- * ```typescript
73
- * const error = new LexServerAuthError('AuthenticationRequired', 'Token required', {
74
- * Bearer: { realm: 'api.example.com', error: 'MissingToken' }
75
- * })
76
- * console.log(error.wwwAuthenticateHeader)
77
- * // Output: 'Bearer realm="api.example.com", error="MissingToken"'
78
- * ```
79
- */
80
- get wwwAuthenticateHeader(): string {
81
- return formatWWWAuthenticateHeader(this.wwwAuthenticate)
82
- }
83
-
84
- /**
85
- * Converts the error to a JSON representation suitable for response bodies.
86
- *
87
- * If the error was created from another LexError (via `from()`), returns
88
- * the original error's JSON representation.
89
- *
90
- * @returns JSON object with error code and message
91
- */
92
- toJSON() {
93
- const { cause } = this
94
- return cause instanceof LexError ? cause.toJSON() : super.toJSON()
95
- }
96
-
97
- /**
98
- * Converts the error to an HTTP 401 Response with WWW-Authenticate header.
99
- *
100
- * The response includes:
101
- * - Status code 401 (Unauthorized)
102
- * - WWW-Authenticate header (if parameters were provided)
103
- * - Access-Control-Expose-Headers for CORS compatibility
104
- * - JSON body with error details
105
- *
106
- * @returns HTTP Response object ready to be sent to the client
107
- */
108
- toResponse(): Response {
109
- const { wwwAuthenticateHeader } = this
110
-
111
- const headers = wwwAuthenticateHeader
117
+ const headers = Object.keys(wwwAuthenticate).length
112
118
  ? new Headers({
113
- 'WWW-Authenticate': wwwAuthenticateHeader,
119
+ 'WWW-Authenticate': formatWWWAuthenticateHeader(wwwAuthenticate),
114
120
  'Access-Control-Expose-Headers': 'WWW-Authenticate', // CORS
115
121
  })
116
122
  : undefined
117
-
118
- return Response.json(this.toJSON(), { status: 401, headers })
123
+ super(401, { error, message }, headers, options)
119
124
  }
120
125
 
121
126
  /**
@@ -130,19 +135,39 @@ export class LexServerAuthError<
130
135
  *
131
136
  * @example
132
137
  * ```typescript
133
- * const lexError = new LexError('AuthenticationRequired', 'Token expired')
134
- * const authError = LexServerAuthError.from(lexError, {
135
- * Bearer: { error: 'ExpiredToken' }
136
- * })
138
+ * function authenticate(token: string): Promise<User> {
139
+ * try {
140
+ * return await validateToken(token)
141
+ * } catch (cause) {
142
+ * throw LexServerAuthError.from(cause, {
143
+ * Bearer: { error: 'InvalidToken' }
144
+ * })
145
+ * }
146
+ * }
137
147
  * ```
138
148
  */
139
149
  static from(
140
- cause: LexError,
150
+ cause: unknown,
141
151
  wwwAuthenticate?: WWWAuthenticate,
142
152
  ): LexServerAuthError {
143
- if (cause instanceof LexServerAuthError) return cause
144
- return new LexServerAuthError(cause.error, cause.message, wwwAuthenticate, {
145
- cause,
146
- })
153
+ if (cause instanceof LexServerAuthError) {
154
+ return cause
155
+ }
156
+
157
+ if (cause instanceof LexError) {
158
+ return new LexServerAuthError(
159
+ cause.error,
160
+ cause.message,
161
+ wwwAuthenticate,
162
+ { cause },
163
+ )
164
+ }
165
+
166
+ return new LexServerAuthError(
167
+ 'AuthenticationRequired',
168
+ 'Authentication failed',
169
+ wwwAuthenticate,
170
+ { cause },
171
+ )
147
172
  }
148
173
  }
package/src/index.ts CHANGED
@@ -1,9 +1,3 @@
1
- export {
2
- LexError,
3
- type LexErrorCode,
4
- type LexErrorData,
5
- } from '@atproto/lex-data'
6
-
7
1
  export * from './errors.js'
8
- export * from './lex-server.js'
2
+ export * from './lex-router.js'
9
3
  export * from './service-auth.js'