@moon-x/node-sdk 0.1.0

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.
package/dist/index.mjs ADDED
@@ -0,0 +1,454 @@
1
+ // src/errors.ts
2
+ var MoonXError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = "MoonXError";
6
+ this.code = code;
7
+ Object.setPrototypeOf(this, new.target.prototype);
8
+ }
9
+ };
10
+ var MalformedTokenError = class extends MoonXError {
11
+ constructor(message = "token is not a well-formed JWT") {
12
+ super("malformed_token", message);
13
+ this.name = "MalformedTokenError";
14
+ Object.setPrototypeOf(this, new.target.prototype);
15
+ }
16
+ };
17
+ var InvalidTokenError = class extends MoonXError {
18
+ constructor(message = "token signature is invalid") {
19
+ super("invalid_signature", message);
20
+ this.name = "InvalidTokenError";
21
+ Object.setPrototypeOf(this, new.target.prototype);
22
+ }
23
+ };
24
+ var ExpiredTokenError = class extends MoonXError {
25
+ constructor(message = "token has expired") {
26
+ super("expired_token", message);
27
+ this.name = "ExpiredTokenError";
28
+ Object.setPrototypeOf(this, new.target.prototype);
29
+ }
30
+ };
31
+ var AudienceMismatchError = class extends MoonXError {
32
+ constructor(expected, got, message = `token audience does not match this app (expected ${expected}, got ${JSON.stringify(got)})`) {
33
+ super("audience_mismatch", message);
34
+ this.name = "AudienceMismatchError";
35
+ Object.setPrototypeOf(this, new.target.prototype);
36
+ }
37
+ };
38
+ var IssuerMismatchError = class extends MoonXError {
39
+ constructor(expected, got, message = `unexpected issuer (expected ${expected}, got ${got})`) {
40
+ super("issuer_mismatch", message);
41
+ this.name = "IssuerMismatchError";
42
+ Object.setPrototypeOf(this, new.target.prototype);
43
+ }
44
+ };
45
+ var SubjectMismatchError = class extends MoonXError {
46
+ constructor(message = "identity token subject does not match access token subject") {
47
+ super("subject_mismatch", message);
48
+ this.name = "SubjectMismatchError";
49
+ Object.setPrototypeOf(this, new.target.prototype);
50
+ }
51
+ };
52
+ var KidMismatchError = class extends MoonXError {
53
+ constructor(message = "token kid does not match any key in JWKS") {
54
+ super("kid_mismatch", message);
55
+ this.name = "KidMismatchError";
56
+ Object.setPrototypeOf(this, new.target.prototype);
57
+ }
58
+ };
59
+ var UnsupportedAlgorithmError = class extends MoonXError {
60
+ constructor(alg) {
61
+ super(
62
+ "unsupported_algorithm",
63
+ `unsupported token algorithm: ${alg} (only ES256 is supported)`
64
+ );
65
+ this.name = "UnsupportedAlgorithmError";
66
+ Object.setPrototypeOf(this, new.target.prototype);
67
+ }
68
+ };
69
+ var JwksFetchError = class extends MoonXError {
70
+ constructor(message) {
71
+ super("jwks_fetch_failed", message);
72
+ this.name = "JwksFetchError";
73
+ Object.setPrototypeOf(this, new.target.prototype);
74
+ }
75
+ };
76
+ var AppResolutionError = class extends MoonXError {
77
+ constructor(message) {
78
+ super("app_resolution_failed", message);
79
+ this.name = "AppResolutionError";
80
+ Object.setPrototypeOf(this, new.target.prototype);
81
+ }
82
+ };
83
+ var ConfigurationError = class extends MoonXError {
84
+ constructor(message) {
85
+ super("configuration_error", message);
86
+ this.name = "ConfigurationError";
87
+ Object.setPrototypeOf(this, new.target.prototype);
88
+ }
89
+ };
90
+
91
+ // src/auth/app-resolver.ts
92
+ var AppResolver = class {
93
+ constructor(baseUrl, publishableKey, opts = {}) {
94
+ this.cached = null;
95
+ this.baseUrl = baseUrl.replace(/\/$/, "");
96
+ this.publishableKey = publishableKey;
97
+ this.ttlMs = opts.ttlMs ?? 60 * 60 * 1e3;
98
+ this.fetcher = opts.fetch ?? globalThis.fetch;
99
+ }
100
+ async resolve() {
101
+ if (this.cached && Date.now() - this.cached.loadedAt < this.ttlMs) {
102
+ return this.cached.appId;
103
+ }
104
+ const appId = await this.fetch();
105
+ this.cached = { appId, loadedAt: Date.now() };
106
+ return appId;
107
+ }
108
+ async fetch() {
109
+ const url = `${this.baseUrl}/v0/sdk/apps/public/config`;
110
+ let res;
111
+ try {
112
+ res = await this.fetcher(url, {
113
+ headers: { Authorization: `PublicToken ${this.publishableKey}` },
114
+ signal: AbortSignal.timeout(5e3)
115
+ });
116
+ } catch (err) {
117
+ throw new AppResolutionError(
118
+ `app config fetch failed: ${err instanceof Error ? err.message : String(err)}`
119
+ );
120
+ }
121
+ if (!res.ok) {
122
+ throw new AppResolutionError(
123
+ `app config fetch returned HTTP ${res.status}`
124
+ );
125
+ }
126
+ const body = await res.json();
127
+ if (!body.id) {
128
+ throw new AppResolutionError("app config response missing 'id' field");
129
+ }
130
+ return body.id;
131
+ }
132
+ };
133
+
134
+ // src/auth/crypto.ts
135
+ async function importEs256JwkPublicKey(jwk) {
136
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || jwk.alg !== "ES256") {
137
+ throw new UnsupportedAlgorithmError(
138
+ `${jwk.alg} (${jwk.kty} ${jwk.crv})`
139
+ );
140
+ }
141
+ return globalThis.crypto.subtle.importKey(
142
+ "jwk",
143
+ {
144
+ kty: jwk.kty,
145
+ crv: jwk.crv,
146
+ x: jwk.x,
147
+ y: jwk.y,
148
+ ext: true
149
+ },
150
+ { name: "ECDSA", namedCurve: "P-256" },
151
+ false,
152
+ ["verify"]
153
+ );
154
+ }
155
+ async function verifyEs256Jwt(token, key) {
156
+ const parts = token.split(".");
157
+ if (parts.length !== 3) {
158
+ throw new MalformedTokenError("expected three segments separated by '.'");
159
+ }
160
+ const [headerB64, payloadB64, sigB64] = parts;
161
+ let header;
162
+ let payload;
163
+ try {
164
+ header = JSON.parse(base64UrlDecodeString(headerB64));
165
+ payload = JSON.parse(base64UrlDecodeString(payloadB64));
166
+ } catch {
167
+ throw new MalformedTokenError(
168
+ "token header or payload is not valid base64url-encoded JSON"
169
+ );
170
+ }
171
+ if (header.alg !== "ES256") {
172
+ throw new UnsupportedAlgorithmError(header.alg ?? "(missing)");
173
+ }
174
+ const signature = base64UrlDecodeBytes(sigB64);
175
+ if (signature.length !== 64) {
176
+ throw new MalformedTokenError(
177
+ `expected 64-byte ES256 signature, got ${signature.length}`
178
+ );
179
+ }
180
+ const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
181
+ const ok = await globalThis.crypto.subtle.verify(
182
+ { name: "ECDSA", hash: { name: "SHA-256" } },
183
+ key,
184
+ signature,
185
+ signingInput
186
+ );
187
+ if (!ok) {
188
+ throw new InvalidTokenError();
189
+ }
190
+ return { header, payload };
191
+ }
192
+ function base64UrlToBase64(s) {
193
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
194
+ return s.replace(/-/g, "+").replace(/_/g, "/") + pad;
195
+ }
196
+ function base64UrlDecodeString(s) {
197
+ return new TextDecoder().decode(base64UrlDecodeBytes(s));
198
+ }
199
+ function base64UrlDecodeBytes(s) {
200
+ const bin = atob(base64UrlToBase64(s));
201
+ const out = new Uint8Array(bin.length);
202
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
203
+ return out;
204
+ }
205
+
206
+ // src/auth/jwks.ts
207
+ var JwksCache = class {
208
+ constructor(baseUrl, opts = {}) {
209
+ this.cache = /* @__PURE__ */ new Map();
210
+ this.baseUrl = baseUrl.replace(/\/$/, "");
211
+ this.ttlMs = opts.ttlMs ?? 5 * 60 * 1e3;
212
+ this.fetcher = opts.fetch ?? globalThis.fetch;
213
+ }
214
+ async getKey(appId) {
215
+ const cached = this.cache.get(appId);
216
+ if (cached && Date.now() - cached.loadedAt < this.ttlMs) {
217
+ return cached;
218
+ }
219
+ const fresh = await this.fetch(appId);
220
+ this.cache.set(appId, fresh);
221
+ return fresh;
222
+ }
223
+ /** Drop the cached entry for an app id. Useful after a 401 / kid mismatch
224
+ * to force a re-fetch on the next call (in case the backend rotated). */
225
+ invalidate(appId) {
226
+ this.cache.delete(appId);
227
+ }
228
+ async fetch(appId) {
229
+ const url = `${this.baseUrl}/v0/sdk/.well-known/jwks/${appId}`;
230
+ let res;
231
+ try {
232
+ res = await this.fetcher(url, {
233
+ signal: AbortSignal.timeout(5e3)
234
+ });
235
+ } catch (err) {
236
+ throw new JwksFetchError(
237
+ `JWKS fetch failed for ${appId}: ${err instanceof Error ? err.message : String(err)}`
238
+ );
239
+ }
240
+ if (!res.ok) {
241
+ throw new JwksFetchError(
242
+ `JWKS fetch returned HTTP ${res.status} for ${appId}`
243
+ );
244
+ }
245
+ const body = await res.json();
246
+ const jwk = body.keys?.find(
247
+ (k) => k.kty === "EC" && k.crv === "P-256" && k.alg === "ES256" && (k.use === "sig" || k.use === void 0)
248
+ );
249
+ if (!jwk) {
250
+ throw new JwksFetchError(
251
+ `JWKS for ${appId} contains no usable ES256 signing key`
252
+ );
253
+ }
254
+ const key = await importEs256JwkPublicKey(jwk);
255
+ return { key, kid: jwk.kid, loadedAt: Date.now() };
256
+ }
257
+ };
258
+
259
+ // src/auth/verify.ts
260
+ async function verify(token, ctx) {
261
+ const parts = token.split(".");
262
+ if (parts.length !== 3) {
263
+ return verifyWithSig(token, ctx);
264
+ }
265
+ let unverifiedPayload;
266
+ try {
267
+ unverifiedPayload = JSON.parse(base64UrlDecodeString2(parts[1]));
268
+ } catch {
269
+ return verifyWithSig(token, ctx);
270
+ }
271
+ assertAudience(unverifiedPayload, ctx.pinnedAppId);
272
+ assertIssuer(unverifiedPayload, ctx.expectedIssuer);
273
+ return verifyWithSig(token, ctx);
274
+ }
275
+ async function verifyWithSig(token, ctx) {
276
+ const cached = await ctx.jwks.getKey(ctx.pinnedAppId);
277
+ const { header, payload } = await verifyEs256Jwt(token, cached.key);
278
+ if (header.kid && header.kid !== cached.kid) {
279
+ ctx.jwks.invalidate(ctx.pinnedAppId);
280
+ throw new KidMismatchError();
281
+ }
282
+ assertAudience(payload, ctx.pinnedAppId);
283
+ assertIssuer(payload, ctx.expectedIssuer);
284
+ assertNotExpired(payload);
285
+ return payload;
286
+ }
287
+ async function verifyAccessToken(token, ctx) {
288
+ const claims = await verify(token, ctx);
289
+ return claims;
290
+ }
291
+ async function verifyIdentityToken(token, ctx) {
292
+ const claims = await verify(token, ctx);
293
+ return claims;
294
+ }
295
+ async function verifySession(args, ctx) {
296
+ const access = await verifyAccessToken(args.accessToken, ctx);
297
+ if (!args.identityToken) {
298
+ return { access, identity: null };
299
+ }
300
+ const identity = await verifyIdentityToken(args.identityToken, ctx);
301
+ if (identity.sub !== access.sub) {
302
+ throw new SubjectMismatchError();
303
+ }
304
+ return { access, identity };
305
+ }
306
+ function assertAudience(payload, pinnedAppId) {
307
+ const raw = payload.aud;
308
+ const aud = Array.isArray(raw) ? raw[0] : raw;
309
+ if (aud !== pinnedAppId) {
310
+ throw new AudienceMismatchError(
311
+ pinnedAppId,
312
+ raw
313
+ );
314
+ }
315
+ }
316
+ function assertIssuer(payload, expected) {
317
+ if (payload.iss !== expected) {
318
+ throw new IssuerMismatchError(expected, String(payload.iss ?? "(missing)"));
319
+ }
320
+ }
321
+ function assertNotExpired(payload) {
322
+ const exp = payload.exp;
323
+ const now = Math.floor(Date.now() / 1e3);
324
+ if (typeof exp !== "number" || exp <= now) {
325
+ throw new ExpiredTokenError();
326
+ }
327
+ }
328
+ function base64UrlDecodeString2(s) {
329
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
330
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
331
+ return new TextDecoder().decode(
332
+ Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
333
+ );
334
+ }
335
+
336
+ // src/auth/index.ts
337
+ var Auth = class {
338
+ constructor(opts) {
339
+ this.resolver = new AppResolver(opts.baseUrl, opts.publishableKey, {
340
+ ttlMs: opts.appResolveTtlMs,
341
+ fetch: opts.fetch
342
+ });
343
+ this.jwks = new JwksCache(opts.baseUrl, {
344
+ ttlMs: opts.jwksTtlMs,
345
+ fetch: opts.fetch
346
+ });
347
+ this.expectedIssuer = opts.expectedIssuer;
348
+ }
349
+ /**
350
+ * Verify an access token. Returns the claims on success; throws a
351
+ * typed `MoonXError` subclass on any failure.
352
+ */
353
+ async verifyAccessToken(token) {
354
+ const pinnedAppId = await this.resolver.resolve();
355
+ return verifyAccessToken(token, {
356
+ pinnedAppId,
357
+ expectedIssuer: this.expectedIssuer,
358
+ jwks: this.jwks
359
+ });
360
+ }
361
+ /**
362
+ * Verify an identity token. Returns the OIDC profile claims on
363
+ * success; throws a typed `MoonXError` subclass on any failure.
364
+ */
365
+ async verifyIdentityToken(token) {
366
+ const pinnedAppId = await this.resolver.resolve();
367
+ return verifyIdentityToken(token, {
368
+ pinnedAppId,
369
+ expectedIssuer: this.expectedIssuer,
370
+ jwks: this.jwks
371
+ });
372
+ }
373
+ /**
374
+ * Verify both tokens (identity optional). Cross-checks that the
375
+ * identity token's subject matches the access token's subject so a
376
+ * caller can't swap in another user's (still-valid) identity token.
377
+ *
378
+ * Canonical entry point for "this request is authorized AND we know
379
+ * who the user is" — call this from your request handlers.
380
+ */
381
+ async verifySession(args) {
382
+ const pinnedAppId = await this.resolver.resolve();
383
+ return verifySession(args, {
384
+ pinnedAppId,
385
+ expectedIssuer: this.expectedIssuer,
386
+ jwks: this.jwks
387
+ });
388
+ }
389
+ };
390
+
391
+ // src/config.ts
392
+ var DEFAULT_ISSUER = "https://api.moonx-dev.com";
393
+ function resolveConfig(input) {
394
+ if (!input.publishableKey) {
395
+ throw new ConfigurationError(
396
+ "MoonXClient: publishableKey is required"
397
+ );
398
+ }
399
+ if (!input.publishableKey.startsWith("moon_pk_")) {
400
+ throw new ConfigurationError(
401
+ "MoonXClient: publishableKey must start with 'moon_pk_' (got a string that doesn't \u2014 did you pass a secret key by mistake?)"
402
+ );
403
+ }
404
+ if (input.secretKey !== void 0 && !input.secretKey.startsWith("moon_sk_")) {
405
+ throw new ConfigurationError(
406
+ "MoonXClient: secretKey must start with 'moon_sk_' (got a string that doesn't)"
407
+ );
408
+ }
409
+ const issuer = input.issuer ?? DEFAULT_ISSUER;
410
+ if (!process.env.MOONX_AUTH_ISSUER_SILENT && input.issuer === void 0) {
411
+ console.warn(
412
+ `[@moon-x/node-sdk] issuer not set on MoonXClient \u2014 defaulting to ${DEFAULT_ISSUER}. Tokens issued by any other MoonX deployment will fail verification.`
413
+ );
414
+ }
415
+ return {
416
+ publishableKey: input.publishableKey,
417
+ secretKey: input.secretKey,
418
+ issuer,
419
+ baseUrl: input.baseUrl ?? issuer,
420
+ jwksTtlMs: input.jwksTtlMs ?? 5 * 60 * 1e3,
421
+ appResolveTtlMs: input.appResolveTtlMs ?? 60 * 60 * 1e3,
422
+ fetch: input.fetch ?? globalThis.fetch
423
+ };
424
+ }
425
+
426
+ // src/client.ts
427
+ var MoonXClient = class {
428
+ constructor(input) {
429
+ const cfg = resolveConfig(input);
430
+ this.auth = new Auth({
431
+ baseUrl: cfg.baseUrl,
432
+ publishableKey: cfg.publishableKey,
433
+ expectedIssuer: cfg.issuer,
434
+ jwksTtlMs: cfg.jwksTtlMs,
435
+ appResolveTtlMs: cfg.appResolveTtlMs,
436
+ fetch: cfg.fetch
437
+ });
438
+ }
439
+ };
440
+ export {
441
+ AppResolutionError,
442
+ AudienceMismatchError,
443
+ ConfigurationError,
444
+ ExpiredTokenError,
445
+ InvalidTokenError,
446
+ IssuerMismatchError,
447
+ JwksFetchError,
448
+ KidMismatchError,
449
+ MalformedTokenError,
450
+ MoonXClient,
451
+ MoonXError,
452
+ SubjectMismatchError,
453
+ UnsupportedAlgorithmError
454
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@moon-x/node-sdk",
3
+ "version": "0.1.0",
4
+ "description": "MoonX server-side SDK for Node.js. Verify MoonX-issued access + identity tokens with zero runtime dependencies.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "moonx",
21
+ "moonpay",
22
+ "auth",
23
+ "jwt",
24
+ "jwks",
25
+ "wallet",
26
+ "sdk"
27
+ ],
28
+ "license": "MIT",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@moon-x/core": "0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "tsup": "8.5.1",
37
+ "typescript": "5.9.3",
38
+ "vitest": "1.6.1"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "dev": "tsup --watch",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "clean": "rm -rf dist .turbo node_modules"
49
+ }
50
+ }