@moon-x/node-sdk
v0.8.0
Published
MoonX server-side SDK for Node.js. Verify MoonX-issued access + identity tokens with zero runtime dependencies.
Readme
@moon-x/node-sdk
MoonX server-side SDK for Node.js. Verify MoonX-issued access and identity tokens with zero runtime dependencies.
Works in Node 18+, Cloudflare Workers, Vercel Edge, Bun, and Deno — anywhere globalThis.crypto.subtle and fetch are available.
Install
npm i @moon-x/node-sdk
# or
pnpm add @moon-x/node-sdkQuick start
import { MoonXClient } from "@moon-x/node-sdk";
const moonx = new MoonXClient({
publishableKey: process.env.MOONX_PUBLISHABLE_KEY!,
issuer: process.env.MOONX_AUTH_ISSUER, // optional, defaults to https://api.moonx-dev.com
});
// In a Next.js route / Express handler / etc.
const session = await moonx.auth.verifySession({
accessToken: req.headers["x-moonx-access-token"]!,
identityToken: req.headers["x-moonx-identity-token"],
});
// session.access.sub — user id
// session.access.sid — session id
// session.identity?.email — OIDC profile claim (present when identity token forwarded)
// session.identity?.name
// session.identity?.pictureWhat it does
When a MoonX-authenticated user calls your backend, the request carries one or both of:
| Header | Token | Carries |
|---|---|---|
| X-MoonX-Access-Token | Access token | Minimal authorization claims (sub, sid, aud, iss, exp) |
| X-MoonX-Identity-Token | Identity token | OIDC profile claims (email, name, picture, etc.) |
verifySession() runs the full canonical check on both:
- Pin audience to your app id (rejects tokens from other MoonX apps even if cryptographically valid).
- Pin issuer to your configured MoonX deployment.
- Fetch the per-app public JWKS from MoonX (cached 5 min by default).
- Verify the ES256 signature against the fetched key.
- Check the token hasn't expired.
- (Identity token path) Verify the identity token's
submatches the access token'ssub— closes the swap-in-someone-else's-identity-token attack.
No shared secret with MoonX is required. The verification runs entirely against the public JWKS.
API
new MoonXClient(config)
Construct once per process. Holds in-memory caches; reuse across requests.
| Field | Type | Required | Default |
|---|---|---|---|
| publishableKey | string | Yes | — |
| secretKey | string | For ephemeralSigners | n/a |
| issuer | string | No | https://api.moonx-dev.com |
| baseUrl | string | No | Same as issuer |
| jwksTtlMs | number | No | 300_000 (5 min) |
| appResolveTtlMs | number | No | 3_600_000 (1 hour) |
| fetch | typeof fetch | No | globalThis.fetch |
The publishable key (moon_pk_*) is the same one your iframe / browser SDK uses; it resolves your app id and scopes JWKS lookups. The secret key (moon_sk_*) is required to use ephemeralSigners (the wallets server-side ephemeral-signer routes are secret-key-only) and by the v0.2 data + swaps modules. Read both from server-only env vars; never expose the secret key to the browser.
client.auth.verifyAccessToken(token)
Returns Promise<AccessTokenClaims>. Throws a MoonXError subclass on any failure.
client.auth.verifyIdentityToken(token)
Returns Promise<IdentityTokenClaims>. Throws a MoonXError subclass on any failure.
client.auth.verifySession({ accessToken, identityToken? })
Returns Promise<{ access: AccessTokenClaims, identity: IdentityTokenClaims | null }>. Identity is null when the caller didn't forward an identity token.
Canonical entry point — use this from request handlers. Cross-checks the two tokens' subjects when both are present.
client.funding.sign(params)
Request a signed MoonPay Buy-widget URL for a user's wallet, so your backend can drive the card-funding flow without holding MoonPay keys. Returns Promise<MoonPaySignResponse>.
The route is publishable-key acceptable but signs only for an address that belongs to the authenticated user, so you forward that user's access token per call:
const funding = await moonx.funding.sign({
accessToken: req.headers["x-moonx-access-token"]!, // the end user's token
publicAddress: "0xUserWalletAddress",
useSandbox: true, // omit or false for production
currencyCode: "eth", // optional MoonPay presets
quoteCurrencyAmount: 50,
});
// funding.signed_url — open this for the user
// funding.external_transaction_id — correlate the purchase
// funding.provider_publishable_key — MoonPay pk the URL was signed with| Param | Type | Required | Notes |
|---|---|---|---|
| accessToken | string | Yes | The end user's MoonX access token |
| publicAddress | string | Yes | Wallet address to fund; must belong to the user |
| useSandbox | boolean | No | true → MoonPay sandbox; defaults to false |
| currencyCode | string | No | MoonPay currency to preselect |
| quoteCurrencyAmount | number | No | Pin the fiat amount; omit to let the user choose |
| paymentMethod | string | No | MoonPay payment method to preselect |
| uiConfig | { theme?, accentColor? } | No | Widget theming |
Failures throw a typed MoonXError subclass: IntegrationNotConfiguredError (400), UnauthorizedError (401), WalletOwnershipError (403), RateLimitedError (429).
Errors
All thrown errors extend MoonXError, which carries a .code property for programmatic handling:
import {
MoonXError,
ExpiredTokenError,
InvalidTokenError,
AudienceMismatchError,
SubjectMismatchError,
// ...
} from "@moon-x/node-sdk";
try {
await moonx.auth.verifySession({ accessToken, identityToken });
} catch (err) {
if (err instanceof ExpiredTokenError) {
return new Response("session expired", { status: 401 });
}
if (err instanceof MoonXError) {
return new Response(err.message, { status: 401 });
}
throw err;
}Error codes
| Class | .code | Meaning |
|---|---|---|
| MalformedTokenError | malformed_token | Not a well-formed JWT |
| InvalidTokenError | invalid_signature | Signature didn't verify against the JWKS |
| ExpiredTokenError | expired_token | exp claim is in the past |
| AudienceMismatchError | audience_mismatch | Token's aud ≠ this client's app id |
| IssuerMismatchError | issuer_mismatch | Token's iss ≠ configured issuer |
| SubjectMismatchError | subject_mismatch | Identity token's sub ≠ access token's sub |
| KidMismatchError | kid_mismatch | Token's kid ≠ current JWKS key id (likely rotation) |
| UnsupportedAlgorithmError | unsupported_algorithm | Token uses something other than ES256 |
| JwksFetchError | jwks_fetch_failed | Couldn't fetch the JWKS from MoonX |
| AppResolutionError | app_resolution_failed | Publishable-key → app-id lookup failed |
| ConfigurationError | configuration_error | Bad MoonXClient config, or a missing required call param |
| IntegrationNotConfiguredError | integration_not_configured | MoonPay funding is not configured or is disabled for this app (400) |
| WalletOwnershipError | wallet_ownership | The wallet address does not belong to the user (403) |
| UnauthorizedError | unauthorized | Missing or invalid credentials (401) |
| RateLimitedError | rate_limited | Request was rate limited (429) |
| RequestFailedError | request_failed | Unexpected non-2xx response (carries .status) |
Type imports
AccessTokenClaims, IdentityTokenClaims, BaseTokenClaims, VerifiedSession, and MoonPaySignResponse are also exported from @moon-x/core/types — the single source of truth shared with the browser + RN SDKs. Either import path works:
// From node-sdk (convenience re-export):
import type { AccessTokenClaims } from "@moon-x/node-sdk";
// From core directly (matches the browser SDK call sites):
import type { AccessTokenClaims } from "@moon-x/core/types";Why no JWT library?
Auth providers ship server SDKs partly to abstract away jose-style JWT libraries, but every dependency is a supply-chain surface. This SDK uses only Web Crypto (globalThis.crypto.subtle) and fetch, both Node 18+ standard library. Result: a single npm install line on your audit.
Roadmap
- Current: auth (
verifyAccessToken,verifyIdentityToken,verifySession),ephemeralSigners(secret-key gated server-side signing), andfunding(MoonPay onramp signing). - Next: adds
client.tokens.*andclient.swaps.*.
