@atproto/lex-server 0.0.1 → 0.0.3

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.
@@ -0,0 +1,67 @@
1
+ import { AtprotoDid, AtprotoDidDocument } from '@atproto/did';
2
+ import { DidString } from '@atproto/lex-schema';
3
+ import { CreateDidResolverOptions } from '@atproto-labs/did-resolver';
4
+ import { LexRouterAuth } from './lex-server.js';
5
+ /**
6
+ * A function to check and record nonce uniqueness.
7
+ */
8
+ export type UniqueNonceChecker = (nonce: string) => Promise<boolean>;
9
+ export type ServiceAuthOptions = CreateDidResolverOptions & {
10
+ /**
11
+ * Expected audience ("aud") claim in the JWT token. Set to `null` to skip
12
+ * audience verification (not recommended).
13
+ */
14
+ audience: null | DidString;
15
+ /**
16
+ * Function to check and record nonce uniqueness. The value checked here must
17
+ * be unique within {@link ServiceAuthOptions.maxAge} seconds before and after
18
+ * the current time.
19
+ *
20
+ * @param nonce - The nonce to check.
21
+ */
22
+ unique: UniqueNonceChecker;
23
+ /**
24
+ * Maximum age of the JWT token in seconds.
25
+ *
26
+ * @default 300 (5 minutes)
27
+ */
28
+ maxAge?: number;
29
+ };
30
+ export type ServiceAuthCredentials = {
31
+ did: AtprotoDid;
32
+ didDocument: AtprotoDidDocument;
33
+ jwt: ParsedJwt;
34
+ };
35
+ /**
36
+ * Creates an authentication handler for LexRouter that verifies AT protocol
37
+ * "service auth" JWT bearer tokens signed by decentralized identifiers (DIDs).
38
+ */
39
+ export declare function serviceAuth({ audience, maxAge, unique, ...options }: ServiceAuthOptions): LexRouterAuth<ServiceAuthCredentials>;
40
+ export type ParseJwtOptions = {
41
+ maxAge: number;
42
+ audience: null | DidString;
43
+ unique: UniqueNonceChecker;
44
+ lxm: string;
45
+ };
46
+ export type ParsedJwt = {
47
+ header: HeaderObject;
48
+ payload: PayloadObject;
49
+ message: Uint8Array;
50
+ signature: Uint8Array;
51
+ };
52
+ type HeaderObject = {
53
+ alg: string;
54
+ typ?: string;
55
+ };
56
+ type PayloadObject = {
57
+ iss: DidString;
58
+ aud: DidString;
59
+ exp: number;
60
+ iat?: number;
61
+ nbf?: number;
62
+ lxm?: string;
63
+ nonce?: string;
64
+ };
65
+ export declare function isPayloadObject(obj: unknown): obj is PayloadObject;
66
+ export {};
67
+ //# sourceMappingURL=service-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-auth.d.ts","sourceRoot":"","sources":["../src/service-auth.ts"],"names":[],"mappings":"AACA,OAAO,EACL,UAAU,EACV,kBAAkB,EAGnB,MAAM,cAAc,CAAA;AAErB,OAAO,EAAE,SAAS,EAAe,MAAM,qBAAqB,CAAA;AAC5D,OAAO,EACL,wBAAwB,EAEzB,MAAM,4BAA4B,CAAA;AAEnC,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAI/C;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;AAEpE,MAAM,MAAM,kBAAkB,GAAG,wBAAwB,GAAG;IAC1D;;;OAGG;IACH,QAAQ,EAAE,IAAI,GAAG,SAAS,CAAA;IAC1B;;;;;;OAMG;IACH,MAAM,EAAE,kBAAkB,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,GAAG,EAAE,UAAU,CAAA;IACf,WAAW,EAAE,kBAAkB,CAAA;IAC/B,GAAG,EAAE,SAAS,CAAA;CACf,CAAA;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,MAAe,EACf,MAAM,EACN,GAAG,OAAO,EACX,EAAE,kBAAkB,GAAG,aAAa,CAAC,sBAAsB,CAAC,CAyD5D;AA2ED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,IAAI,GAAG,SAAS,CAAA;IAC1B,MAAM,EAAE,kBAAkB,CAAA;IAC1B,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,YAAY,CAAA;IACpB,OAAO,EAAE,aAAa,CAAA;IACtB,OAAO,EAAE,UAAU,CAAA;IACnB,SAAS,EAAE,UAAU,CAAA;CACtB,CAAA;AA4HD,KAAK,YAAY,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AASjD,KAAK,aAAa,GAAG;IACnB,GAAG,EAAE,SAAS,CAAA;IACd,GAAG,EAAE,SAAS,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AACD,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,aAAa,CAalE"}
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serviceAuth = serviceAuth;
4
+ exports.isPayloadObject = isPayloadObject;
5
+ const tslib_1 = require("tslib");
6
+ const crypto = tslib_1.__importStar(require("@atproto/crypto"));
7
+ const did_1 = require("@atproto/did");
8
+ const lex_data_1 = require("@atproto/lex-data");
9
+ const lex_schema_1 = require("@atproto/lex-schema");
10
+ const did_resolver_1 = require("@atproto-labs/did-resolver");
11
+ const errors_js_1 = require("./errors.js");
12
+ const BEARER_PREFIX = 'Bearer ';
13
+ /**
14
+ * Creates an authentication handler for LexRouter that verifies AT protocol
15
+ * "service auth" JWT bearer tokens signed by decentralized identifiers (DIDs).
16
+ */
17
+ function serviceAuth({ audience, maxAge = 5 * 60, unique, ...options }) {
18
+ const didResolver = (0, did_resolver_1.createDidResolver)(options);
19
+ return async ({ request, method }) => {
20
+ const { signal } = request;
21
+ const jwt = await parseJwtBearer(request, {
22
+ lxm: method.nsid,
23
+ maxAge,
24
+ audience,
25
+ unique,
26
+ });
27
+ let didDocument = await didResolver
28
+ .resolve(jwt.payload.iss, { signal })
29
+ .catch((cause) => {
30
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Could not resolve DID document', { Bearer: { error: 'DidResolutionFailed' } }, { cause });
31
+ });
32
+ const key = getAtprotoSigningKey(didDocument);
33
+ if (!key || !(await verifyJwt(jwt, key))) {
34
+ signal.throwIfAborted();
35
+ // Try refreshing the DID document in case it was updated
36
+ didDocument = await didResolver
37
+ .resolve(jwt.payload.iss, { signal, noCache: true })
38
+ .catch((cause) => {
39
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Could not resolve DID document', { Bearer: { error: 'DidResolutionFailed' } }, { cause });
40
+ });
41
+ // Verify again with the fresh key (if it changed)
42
+ const keyFresh = getAtprotoSigningKey(didDocument);
43
+ if (!keyFresh || key === keyFresh || !(await verifyJwt(jwt, keyFresh))) {
44
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT signature', { Bearer: { error: 'BadJwtSignature' } });
45
+ }
46
+ }
47
+ return {
48
+ did: didDocument.id,
49
+ didDocument,
50
+ jwt,
51
+ };
52
+ };
53
+ }
54
+ async function verifyJwt(jwt, key) {
55
+ try {
56
+ return await crypto.verifySignature(key, jwt.message, jwt.signature, {
57
+ jwtAlg: jwt.header.alg,
58
+ allowMalleableSig: true,
59
+ });
60
+ }
61
+ catch (cause) {
62
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Could not verify JWT signature', { Bearer: { error: 'BadJwtSignature' } }, { cause });
63
+ }
64
+ }
65
+ function getAtprotoSigningKey(didDocument) {
66
+ try {
67
+ const key = didDocument.verificationMethod?.find(isAtprotoVerificationMethod, didDocument);
68
+ if (key?.publicKeyMultibase) {
69
+ if (key.type === 'EcdsaSecp256r1VerificationKey2019') {
70
+ const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase);
71
+ return crypto.formatDidKey(crypto.P256_JWT_ALG, keyBytes);
72
+ }
73
+ else if (key.type === 'EcdsaSecp256k1VerificationKey2019') {
74
+ const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase);
75
+ return crypto.formatDidKey(crypto.SECP256K1_JWT_ALG, keyBytes);
76
+ }
77
+ else if (key.type === 'Multikey') {
78
+ const parsed = crypto.parseMultikey(key.publicKeyMultibase);
79
+ return crypto.formatDidKey(parsed.jwtAlg, parsed.keyBytes);
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Invalid key, ignore
85
+ }
86
+ return null;
87
+ }
88
+ function isAtprotoVerificationMethod(vm) {
89
+ return typeof vm === 'object' && (0, did_1.matchesIdentifier)(this.id, 'atproto', vm.id);
90
+ }
91
+ async function parseJwtBearer(request, options) {
92
+ const authorization = request.headers.get('authorization');
93
+ if (!authorization?.startsWith(BEARER_PREFIX)) {
94
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Bearer token required', { Bearer: { error: 'MissingBearer' } });
95
+ }
96
+ const token = authorization.slice(BEARER_PREFIX.length).trim();
97
+ return parseJwt(token, options);
98
+ }
99
+ async function parseJwt(token, options) {
100
+ const { length, 0: headerB64, 1: payloadB64, 2: signatureB64, } = token.split('.');
101
+ if (length !== 3) {
102
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT token', { Bearer: { error: 'BadJwt' } });
103
+ }
104
+ let header;
105
+ try {
106
+ header = jsonFromBase64(headerB64, isHeaderObject);
107
+ }
108
+ catch (cause) {
109
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT token', { Bearer: { error: 'BadJwt' } }, { cause });
110
+ }
111
+ if (header.alg === 'none' ||
112
+ // service tokens are not OAuth 2.0 access tokens
113
+ // https://datatracker.ietf.org/doc/html/rfc9068
114
+ header.typ === 'at+jwt' ||
115
+ // "refresh+jwt" is a non-standard type used by the @atproto packages
116
+ header.typ === 'refresh+jwt' ||
117
+ // "DPoP" proofs are not meant to be used as service tokens
118
+ // https://datatracker.ietf.org/doc/html/rfc9449
119
+ header.typ === 'dpop+jwt') {
120
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT token', { Bearer: { error: 'BadJwt' } });
121
+ }
122
+ let payload;
123
+ try {
124
+ payload = jsonFromBase64(payloadB64, isPayloadObject);
125
+ }
126
+ catch (cause) {
127
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT token', { Bearer: { error: 'BadJwt' } }, { cause });
128
+ }
129
+ if (options.audience !== null && options.audience !== payload.aud) {
130
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid audience', {
131
+ Bearer: { error: 'InvalidAudience' },
132
+ });
133
+ }
134
+ const now = Math.floor(Date.now() / 1000);
135
+ if (payload.nbf != null && now < payload.nbf) {
136
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'JWT token not yet valid', { Bearer: { error: 'JwtNotYetValid' } });
137
+ }
138
+ if (now > payload.exp) {
139
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'JWT token expired', { Bearer: { error: 'JwtExpired' } });
140
+ }
141
+ // Prevent issuer from generating very long-lived tokens
142
+ if (timeDiff(now, payload.exp) > options.maxAge ||
143
+ timeDiff(now, payload.iat) > options.maxAge) {
144
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'JWT token too old', { Bearer: { error: 'JwtTooOld' } });
145
+ }
146
+ if (payload.lxm != null && typeof payload.lxm !== options.lxm) {
147
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Invalid JWT lexicon method ("lxm")', { Bearer: { error: 'BadJwtLexiconMethod' } });
148
+ }
149
+ if (payload.nonce != null && !(await (0, options.unique)(payload.nonce))) {
150
+ throw new errors_js_1.LexServerAuthError('AuthenticationRequired', 'Replay attack detected: nonce is not unique', { Bearer: { error: 'NonceNotUnique' } });
151
+ }
152
+ return {
153
+ header,
154
+ payload,
155
+ message: textEncoder.encode(`${headerB64}.${payloadB64}`),
156
+ signature: (0, lex_data_1.fromBase64)(signatureB64, 'base64url'),
157
+ };
158
+ }
159
+ const textEncoder = /*#__PURE__*/ new TextEncoder();
160
+ function isHeaderObject(obj) {
161
+ return ((0, lex_data_1.isPlainObject)(obj) &&
162
+ typeof obj.alg === 'string' &&
163
+ (obj.typ === undefined || typeof obj.typ === 'string'));
164
+ }
165
+ function isPayloadObject(obj) {
166
+ return ((0, lex_data_1.isPlainObject)(obj) &&
167
+ typeof obj.iss === 'string' &&
168
+ typeof obj.aud === 'string' &&
169
+ (obj.lxm === undefined || typeof obj.lxm === 'string') &&
170
+ (obj.nonce === undefined || typeof obj.nonce === 'string') &&
171
+ (obj.iat === undefined || isPositiveInt(obj.iat)) &&
172
+ (obj.nbf === undefined || isPositiveInt(obj.nbf)) &&
173
+ isPositiveInt(obj.exp) &&
174
+ (0, lex_schema_1.isDidString)(obj.iss) &&
175
+ (0, lex_schema_1.isDidString)(obj.aud));
176
+ }
177
+ function timeDiff(t1, t2) {
178
+ if (t2 === undefined)
179
+ return 0;
180
+ return Math.abs(t1 - t2);
181
+ }
182
+ function isPositiveInt(value) {
183
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
184
+ }
185
+ function jsonFromBase64(b64, isType) {
186
+ const obj = JSON.parse((0, lex_data_1.utf8FromBase64)(b64, 'base64url'));
187
+ if (isType(obj))
188
+ return obj;
189
+ throw new Error('Invalid type');
190
+ }
191
+ //# sourceMappingURL=service-auth.js.map
@@ -0,0 +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 * A function to check and record nonce uniqueness.\n */\nexport type UniqueNonceChecker = (nonce: string) => Promise<boolean>\n\nexport type ServiceAuthOptions = CreateDidResolverOptions & {\n /**\n * Expected audience (\"aud\") claim in the JWT token. Set to `null` to skip\n * audience verification (not recommended).\n */\n audience: null | DidString\n /**\n * Function to check and record nonce uniqueness. The value checked here must\n * be unique within {@link ServiceAuthOptions.maxAge} seconds before and after\n * the current time.\n *\n * @param nonce - The nonce to check.\n */\n unique: UniqueNonceChecker\n /**\n * Maximum age of the JWT token in seconds.\n *\n * @default 300 (5 minutes)\n */\n maxAge?: number\n}\n\nexport type ServiceAuthCredentials = {\n did: AtprotoDid\n didDocument: AtprotoDidDocument\n jwt: ParsedJwt\n}\n\n/**\n * Creates an authentication handler for LexRouter that verifies AT protocol\n * \"service auth\" JWT bearer tokens signed by decentralized identifiers (DIDs).\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\nexport type ParseJwtOptions = {\n maxAge: number\n audience: null | DidString\n unique: UniqueNonceChecker\n lxm: string\n}\n\nexport type ParsedJwt = {\n header: HeaderObject\n payload: PayloadObject\n message: Uint8Array\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.1",
3
+ "version": "0.0.3",
4
4
  "license": "MIT",
5
5
  "description": "Request router for Atproto Lexicon protocols and schemas",
6
6
  "keywords": [
@@ -45,17 +45,20 @@
45
45
  "http-terminator": "^3.2.0",
46
46
  "tslib": "^2.8.1",
47
47
  "ws": "^8.18.3",
48
- "@atproto/lex-cbor": "0.0.5",
49
- "@atproto/lex-data": "0.0.5",
50
- "@atproto/lex-schema": "0.0.6",
51
- "@atproto/lex-json": "0.0.5"
48
+ "@atproto-labs/did-resolver": "0.2.5",
49
+ "@atproto/crypto": "0.4.5",
50
+ "@atproto/did": "0.2.4",
51
+ "@atproto/lex-cbor": "0.0.6",
52
+ "@atproto/lex-data": "0.0.6",
53
+ "@atproto/lex-json": "0.0.6",
54
+ "@atproto/lex-schema": "0.0.7"
52
55
  },
53
56
  "devDependencies": {
54
57
  "@types/ws": "^8.18.1",
55
58
  "@vitest/coverage-v8": "4.0.16",
56
59
  "vitest": "^4.0.16",
57
- "@atproto/lex": "0.0.8",
58
- "@atproto/lex-client": "0.0.6"
60
+ "@atproto/lex": "0.0.9",
61
+ "@atproto/lex-client": "0.0.7"
59
62
  },
60
63
  "scripts": {
61
64
  "build": "tsc --build tsconfig.build.json",
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export {
6
6
 
7
7
  export * from './errors.js'
8
8
  export * from './lex-server.js'
9
+ export * from './service-auth.js'
@@ -287,7 +287,7 @@ describe('Authentication', () => {
287
287
  function createBasicAuth(allowed: {
288
288
  username: string
289
289
  password: string
290
- }): LexRouterAuth<typeof io.example.authTest, BasicAuthCredentials> {
290
+ }): LexRouterAuth<BasicAuthCredentials> {
291
291
  return async ({ request }) => {
292
292
  const header = request.headers.get('authorization') ?? ''
293
293
  if (!header.startsWith('Basic ')) {
@@ -1514,7 +1514,7 @@ describe('Subscription', () => {
1514
1514
 
1515
1515
  const router = new LexRouter({ upgradeWebSocket }).add(
1516
1516
  io.example.subscribe,
1517
- async function* ({ params: { message }, request: { signal } }) {
1517
+ async function* ({ params: { message }, signal }) {
1518
1518
  try {
1519
1519
  for (; sentCount < 10; ) {
1520
1520
  await scheduler.wait(5, { signal })
package/src/lex-server.ts CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  } from '@atproto/lex-schema'
19
19
  import { drainWebsocket } from './lib/drain-websocket.js'
20
20
 
21
- type LexMethod = Query | Procedure | Subscription
21
+ type Awaitable<T> = T | Promise<T>
22
+ export type LexMethod = Query | Procedure | Subscription
22
23
 
23
24
  export type NetAddr = {
24
25
  hostname: string
@@ -31,11 +32,11 @@ export type UnixAddr = {
31
32
  transport: 'unix' | 'unixpacket'
32
33
  }
33
34
 
34
- export type Addr = NetAddr | UnixAddr
35
+ export type Addr = NetAddr | UnixAddr | undefined
35
36
 
36
- export type ConnectionInfo = {
37
- localAddr?: Addr
38
- remoteAddr?: Addr
37
+ export type ConnectionInfo<A extends Addr = Addr> = {
38
+ remoteAddr: A
39
+ completed: Promise<void>
39
40
  }
40
41
 
41
42
  type Handler = (
@@ -48,6 +49,7 @@ export type LexRouterHandlerContext<Method extends LexMethod, Credentials> = {
48
49
  input: InferMethodInput<Method, Body>
49
50
  params: InferMethodParams<Method>
50
51
  request: Request
52
+ signal: AbortSignal
51
53
  connection?: ConnectionInfo
52
54
  }
53
55
 
@@ -72,14 +74,14 @@ export type LexRouterMethodHandler<
72
74
  Credentials = unknown,
73
75
  > = (
74
76
  ctx: LexRouterHandlerContext<Method, Credentials>,
75
- ) => Promise<LexRouterHandlerOutput<Method>>
77
+ ) => Awaitable<LexRouterHandlerOutput<Method>>
76
78
 
77
79
  export type LexRouterMethodConfig<
78
80
  Method extends Query | Procedure = Query | Procedure,
79
81
  Credentials = unknown,
80
82
  > = {
81
83
  handler: LexRouterMethodHandler<Method, Credentials>
82
- auth: LexRouterAuth<Method, Credentials>
84
+ auth: LexRouterAuth<Credentials, Method>
83
85
  }
84
86
 
85
87
  export type LexRouterSubscriptionHandler<
@@ -94,18 +96,19 @@ export type LexRouterSubscriptionConfig<
94
96
  Credentials = unknown,
95
97
  > = {
96
98
  handler: LexRouterSubscriptionHandler<Method, Credentials>
97
- auth: LexRouterAuth<Method, Credentials>
99
+ auth: LexRouterAuth<Credentials, Method>
98
100
  }
99
101
 
100
102
  export type LexRouterAuthContext<Method extends LexMethod = LexMethod> = {
103
+ method: Method
101
104
  params: InferMethodParams<Method>
102
105
  request: Request
103
106
  connection?: ConnectionInfo
104
107
  }
105
108
 
106
109
  export type LexRouterAuth<
107
- Method extends LexMethod = LexMethod,
108
110
  Credentials = unknown,
111
+ Method extends LexMethod = LexMethod,
109
112
  > = (ctx: LexRouterAuthContext<Method>) => Credentials | Promise<Credentials>
110
113
 
111
114
  export type LexErrorHandlerContext = {
@@ -185,7 +188,7 @@ export class LexRouter {
185
188
  private buildMethodHandler<Method extends Query | Procedure, Credentials>(
186
189
  method: Method,
187
190
  methodHandler: LexRouterMethodHandler<Method, Credentials>,
188
- auth?: LexRouterAuth<Method, Credentials>,
191
+ auth?: LexRouterAuth<Credentials, Method>,
189
192
  ): Handler {
190
193
  const getInput = (
191
194
  method.type === 'procedure'
@@ -193,10 +196,7 @@ export class LexRouter {
193
196
  : getQueryInput.bind(method)
194
197
  ) as (request: Request) => Promise<InferMethodInput<Method, Body>>
195
198
 
196
- return async (
197
- request: Request,
198
- connection?: ConnectionInfo,
199
- ): Promise<Response> => {
199
+ return async (request, connection) => {
200
200
  // @NOTE CORS requests should be handled by a middleware before reaching
201
201
  // this point.
202
202
  if (
@@ -216,7 +216,7 @@ export class LexRouter {
216
216
  const params = method.parameters.fromURLSearchParams(url.searchParams)
217
217
 
218
218
  const credentials = auth
219
- ? await auth({ params, request, connection })
219
+ ? await auth({ method, params, request, connection })
220
220
  : (undefined as Credentials)
221
221
 
222
222
  const input = await getInput(request)
@@ -227,6 +227,7 @@ export class LexRouter {
227
227
  input,
228
228
  request,
229
229
  connection,
230
+ signal: request.signal,
230
231
  })
231
232
 
232
233
  if (output instanceof Response) {
@@ -258,7 +259,7 @@ export class LexRouter {
258
259
  private buildSubscriptionHandler<Method extends Subscription, Credentials>(
259
260
  method: Method,
260
261
  methodHandler: LexRouterSubscriptionHandler<Method, Credentials>,
261
- auth?: LexRouterAuth<Method, Credentials>,
262
+ auth?: LexRouterAuth<Credentials, Method>,
262
263
  ): Handler {
263
264
  const {
264
265
  onHandlerError,
@@ -272,10 +273,7 @@ export class LexRouter {
272
273
  )
273
274
  }
274
275
 
275
- return async (
276
- request: Request,
277
- connection?: ConnectionInfo,
278
- ): Promise<Response> => {
276
+ return async (request, connection) => {
279
277
  if (request.method !== 'GET') {
280
278
  return Response.json(
281
279
  { error: 'InvalidRequest', message: 'Method not allowed' },
@@ -302,19 +300,34 @@ export class LexRouter {
302
300
  )
303
301
  }
304
302
 
303
+ if (request.signal.aborted) {
304
+ return Response.json(
305
+ { error: 'RequestAborted', message: 'The request was aborted' },
306
+ { status: 499 },
307
+ )
308
+ }
309
+
305
310
  try {
306
311
  const { response, socket } = upgradeWebSocket(request)
307
312
 
308
- socket.addEventListener('message', () => {
313
+ // @NOTE We are using a distinct signal than request.signal because that
314
+ // signal may get aborted before the WebSocket is closed (this is the
315
+ // case with Deno).
316
+ const abortController = new AbortController()
317
+ const { signal } = abortController
318
+ const abort = () => abortController.abort()
319
+
320
+ const onMessage = (event: unknown) => {
309
321
  const error = new LexError(
310
322
  'InvalidRequest',
311
323
  'XRPC subscriptions do not accept messages',
324
+ { cause: event },
312
325
  )
313
326
  socket.send(encodeErrorFrame(error))
314
327
  socket.close(1008, error.error)
315
- })
328
+ }
316
329
 
317
- socket.addEventListener('open', async () => {
330
+ const onOpen = async () => {
318
331
  try {
319
332
  const url = new URL(request.url)
320
333
  const params = method.parameters.fromURLSearchParams(
@@ -322,10 +335,10 @@ export class LexRouter {
322
335
  )
323
336
 
324
337
  const credentials: Credentials = auth
325
- ? await auth({ params, request, connection })
338
+ ? await auth({ method, params, request, connection })
326
339
  : (undefined as Credentials)
327
340
 
328
- request.signal.throwIfAborted()
341
+ signal.throwIfAborted()
329
342
 
330
343
  const iterable = methodHandler({
331
344
  credentials,
@@ -333,31 +346,20 @@ export class LexRouter {
333
346
  input: undefined as InferMethodInput<Method, Body>,
334
347
  request,
335
348
  connection,
349
+ signal,
336
350
  })
337
351
 
338
352
  const iterator = iterable[Symbol.asyncIterator]()
339
353
 
340
- if (iterator.return) {
341
- const abort = async () => {
342
- socket.removeEventListener('error', abort)
343
- socket.removeEventListener('close', abort)
344
- try {
345
- await iterator.return!()
346
- } catch {
347
- // Ignore
348
- }
349
- }
350
- socket.addEventListener('error', abort)
351
- socket.addEventListener('close', abort)
352
- }
354
+ signal.addEventListener('abort', async () => {
355
+ // @NOTE will cause the process to crash if this throws
356
+ await iterator.return?.()
357
+ })
353
358
 
354
- while (socket.readyState === 1) {
359
+ while (!signal.aborted && socket.readyState === 1) {
355
360
  const result = await iterator.next()
356
361
  if (result.done) break
357
362
 
358
- // Should not be needed (socket would emit "close" event)
359
- request.signal.throwIfAborted()
360
-
361
363
  // @TODO add validation of output based on method.output.schema?
362
364
 
363
365
  const data = encodeMessageFrame(method, result.value)
@@ -366,10 +368,12 @@ export class LexRouter {
366
368
 
367
369
  // Apply backpressure by waiting for the buffered data to drain
368
370
  // before generating the next message
369
- await drainWebsocket(socket, request.signal, this.options)
371
+ await drainWebsocket(socket, signal, this.options)
370
372
  }
371
373
 
372
- socket.close(1000)
374
+ if (socket.readyState === 1) {
375
+ socket.close(1000)
376
+ }
373
377
  } catch (error) {
374
378
  // If the socket is still open, send an error frame before closing
375
379
  if (socket.readyState === 1) {
@@ -391,8 +395,15 @@ export class LexRouter {
391
395
  if (onHandlerError && !isAbortReason(request.signal, error)) {
392
396
  await onHandlerError({ error, request, method })
393
397
  }
398
+ } finally {
399
+ abortController.abort()
394
400
  }
395
- })
401
+ }
402
+
403
+ socket.addEventListener('error', abort)
404
+ socket.addEventListener('close', abort)
405
+ socket.addEventListener('open', onOpen)
406
+ socket.addEventListener('message', onMessage)
396
407
 
397
408
  return response
398
409
  } catch (error) {