@ulexid/node 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @ulexid/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Version `0.1.1` — [Ulexid Documentation](https://docs.ulexid.com)
4
+
5
+ Node.js server SDK for [Ulexid](https://www.ulexid.com) — JWT/JWKS verification, session middleware, and webhooks.
6
+
7
+ ```bash
8
+ npm install @ulexid/node
9
+ ```
10
+
11
+ ## License
12
+
13
+ Proprietary (`UNLICENSED`). Copyright (c) Ulexid. All rights reserved.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `@ulexid/node` — Official server-side Node.js & Edge SDK for ulex.
3
+ *
4
+ * Capabilities:
5
+ * - Local JWKS caching and JWT access/ID token verification (`verifyAccessToken`)
6
+ * - M2M `client_credentials` token acquisition with automatic expiry caching
7
+ * - RFC 8693 server-side token exchange for scoped agent credentials
8
+ * - Raw-body Svix-style webhook verification (`ulex-signature`, `ulex-timestamp`, `ulex-id`)
9
+ * with strict 5-minute replay tolerance (Invariant #9)
10
+ * - Typed Management REST helpers (`getUsage`, `listUsers`, `listOrganizations`)
11
+ */
12
+ import { type JWTPayload } from 'jose';
13
+ import { UlexClient, type FetchLike, type TokenExchangeOptions, type TokenSet } from '@ulexid/js';
14
+ export { UlexOAuthError, type FetchLike, type TokenSet } from '@ulexid/js';
15
+ export declare const WEBHOOK_SIGNATURE_HEADER = "ulex-signature";
16
+ export declare const WEBHOOK_TIMESTAMP_HEADER = "ulex-timestamp";
17
+ export declare const WEBHOOK_ID_HEADER = "ulex-id";
18
+ export interface UlexServerClientConfig {
19
+ readonly issuer: string;
20
+ readonly clientId?: string;
21
+ readonly clientSecret?: string;
22
+ /** Management API key (`ulex_live_...` or `ulex_test_...`) for `/api/v1/*` endpoints. */
23
+ readonly apiKey?: string;
24
+ /** Expected JWT `aud` claim when verifying access tokens. */
25
+ readonly audience?: string | readonly string[];
26
+ /** JWKS cache TTL in milliseconds (default: 5 minutes). */
27
+ readonly jwksTtlMs?: number;
28
+ readonly fetch?: FetchLike;
29
+ }
30
+ export interface VerifyAccessTokenOptions {
31
+ readonly audience?: string | readonly string[];
32
+ readonly requiredScopes?: readonly string[];
33
+ readonly clockToleranceSeconds?: number;
34
+ }
35
+ export interface VerifiedAccessToken {
36
+ readonly sub: string;
37
+ readonly iss: string;
38
+ readonly aud?: string | readonly string[];
39
+ readonly exp?: number;
40
+ readonly iat?: number;
41
+ readonly scopes: readonly string[];
42
+ readonly orgId?: string;
43
+ readonly clientId?: string;
44
+ readonly claims: JWTPayload;
45
+ }
46
+ export interface WebhookVerifyOptions {
47
+ /** Maximum age of webhook timestamp in seconds (default: 300 = 5 minutes). */
48
+ readonly toleranceSeconds?: number;
49
+ /** Maximum future skew in seconds (default: 60). */
50
+ readonly futureToleranceSeconds?: number;
51
+ /** Override current epoch milliseconds for deterministic testing. */
52
+ readonly nowMs?: number;
53
+ }
54
+ export type WebhookFailureReason = 'missing_signature' | 'missing_timestamp' | 'missing_id' | 'malformed_timestamp' | 'timestamp_out_of_tolerance' | 'signature_mismatch';
55
+ export declare class UlexWebhookVerificationError extends Error {
56
+ readonly reason: WebhookFailureReason;
57
+ constructor(reason: WebhookFailureReason, message: string);
58
+ }
59
+ export declare class UlexTokenVerificationError extends Error {
60
+ readonly code: string;
61
+ constructor(code: string, message: string);
62
+ }
63
+ /**
64
+ * Verifies a ulex webhook against the **raw unparsed UTF-8 body** (Invariant #9)
65
+ * and rejects timestamps older than 5 minutes by default.
66
+ */
67
+ export declare function verifyWebhookSignature<T = Record<string, unknown>>(rawBody: string | Buffer, headers: Headers | Record<string, string | readonly string[] | undefined>, secret: string | Buffer, options?: WebhookVerifyOptions): T;
68
+ export interface TenantUsageResponse {
69
+ readonly usage: Record<string, unknown>;
70
+ readonly requests: Record<string, number>;
71
+ readonly billing: Record<string, unknown>;
72
+ readonly plan_limits_enforced: boolean;
73
+ }
74
+ export declare class UlexServerClient {
75
+ readonly issuer: string;
76
+ readonly oauth: UlexClient;
77
+ private readonly clientId?;
78
+ private readonly clientSecret?;
79
+ private readonly apiKey?;
80
+ private readonly defaultAudience?;
81
+ private readonly jwksTtlMs;
82
+ private readonly fetchImpl;
83
+ private cachedJwks;
84
+ private cachedM2M;
85
+ constructor(config: UlexServerClientConfig);
86
+ /**
87
+ * Verifies a JWT access or ID token against the tenant's JWKS (`/oauth2/keys`),
88
+ * checking cryptographic signature, `iss`, `exp`, optional `aud`, and `requiredScopes`.
89
+ */
90
+ verifyAccessToken(token: string, options?: VerifyAccessTokenOptions): Promise<VerifiedAccessToken>;
91
+ /**
92
+ * Acquires and caches an M2M `client_credentials` token, refreshing 30 seconds
93
+ * prior to expiration.
94
+ */
95
+ getM2MToken(scopes?: readonly string[]): Promise<TokenSet>;
96
+ /**
97
+ * Server-authenticated RFC 8693 token exchange (`urn:ietf:params:oauth:grant-type:token-exchange`).
98
+ */
99
+ exchangeToken(options: Omit<TokenExchangeOptions, 'clientSecret'>): Promise<TokenSet>;
100
+ getUsage(): Promise<TenantUsageResponse>;
101
+ listUsers(limit?: number, cursor?: string): Promise<{
102
+ data: unknown[];
103
+ next_cursor: string | null;
104
+ }>;
105
+ listOrganizations(limit?: number, cursor?: string): Promise<{
106
+ data: unknown[];
107
+ next_cursor: string | null;
108
+ }>;
109
+ private apiGet;
110
+ private getJwks;
111
+ }
package/dist/index.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `@ulexid/node` — Official server-side Node.js & Edge SDK for ulex.
3
+ *
4
+ * Capabilities:
5
+ * - Local JWKS caching and JWT access/ID token verification (`verifyAccessToken`)
6
+ * - M2M `client_credentials` token acquisition with automatic expiry caching
7
+ * - RFC 8693 server-side token exchange for scoped agent credentials
8
+ * - Raw-body Svix-style webhook verification (`ulex-signature`, `ulex-timestamp`, `ulex-id`)
9
+ * with strict 5-minute replay tolerance (Invariant #9)
10
+ * - Typed Management REST helpers (`getUsage`, `listUsers`, `listOrganizations`)
11
+ */
12
+ import { createHmac, timingSafeEqual } from 'node:crypto';
13
+ import { createLocalJWKSet, jwtVerify } from 'jose';
14
+ import { UlexClient, UlexOAuthError, } from '@ulexid/js';
15
+ export { UlexOAuthError } from '@ulexid/js';
16
+ export const WEBHOOK_SIGNATURE_HEADER = 'ulex-signature';
17
+ export const WEBHOOK_TIMESTAMP_HEADER = 'ulex-timestamp';
18
+ export const WEBHOOK_ID_HEADER = 'ulex-id';
19
+ export class UlexWebhookVerificationError extends Error {
20
+ reason;
21
+ constructor(reason, message) {
22
+ super(message);
23
+ this.name = 'UlexWebhookVerificationError';
24
+ this.reason = reason;
25
+ }
26
+ }
27
+ export class UlexTokenVerificationError extends Error {
28
+ code;
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.name = 'UlexTokenVerificationError';
32
+ this.code = code;
33
+ }
34
+ }
35
+ function getHeaderCaseInsensitive(headers, name) {
36
+ if (headers instanceof Headers) {
37
+ return headers.get(name) ?? undefined;
38
+ }
39
+ const lower = name.toLowerCase();
40
+ for (const [k, v] of Object.entries(headers)) {
41
+ if (k.toLowerCase() === lower) {
42
+ if (typeof v === 'string')
43
+ return v;
44
+ if (Array.isArray(v))
45
+ return v[0];
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ /**
51
+ * Verifies a ulex webhook against the **raw unparsed UTF-8 body** (Invariant #9)
52
+ * and rejects timestamps older than 5 minutes by default.
53
+ */
54
+ export function verifyWebhookSignature(rawBody, headers, secret, options = {}) {
55
+ const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
56
+ const sigHeader = getHeaderCaseInsensitive(headers, WEBHOOK_SIGNATURE_HEADER);
57
+ const tsHeader = getHeaderCaseInsensitive(headers, WEBHOOK_TIMESTAMP_HEADER);
58
+ const idHeader = getHeaderCaseInsensitive(headers, WEBHOOK_ID_HEADER);
59
+ if (!sigHeader) {
60
+ throw new UlexWebhookVerificationError('missing_signature', 'Missing `ulex-signature` header.');
61
+ }
62
+ if (!tsHeader) {
63
+ throw new UlexWebhookVerificationError('missing_timestamp', 'Missing `ulex-timestamp` header.');
64
+ }
65
+ if (!idHeader) {
66
+ throw new UlexWebhookVerificationError('missing_id', 'Missing `ulex-id` header.');
67
+ }
68
+ const timestampSec = Number(tsHeader);
69
+ if (!Number.isFinite(timestampSec)) {
70
+ throw new UlexWebhookVerificationError('malformed_timestamp', 'Invalid numeric `ulex-timestamp`.');
71
+ }
72
+ const nowSec = Math.floor((options.nowMs ?? Date.now()) / 1000);
73
+ const tolerance = options.toleranceSeconds ?? 300;
74
+ const futureTolerance = options.futureToleranceSeconds ?? 60;
75
+ const age = nowSec - timestampSec;
76
+ if (age > tolerance || age < -futureTolerance) {
77
+ throw new UlexWebhookVerificationError('timestamp_out_of_tolerance', `Webhook timestamp ${timestampSec} is outside the ${tolerance}s replay window.`);
78
+ }
79
+ const signedPayload = `${idHeader}.${timestampSec}.${bodyStr}`;
80
+ const expectedMac = createHmac('sha256', secret).update(signedPayload, 'utf8').digest();
81
+ const candidates = sigHeader.split(/\s+/).filter(Boolean);
82
+ let matched = false;
83
+ for (const token of candidates) {
84
+ const commaIdx = token.indexOf(',');
85
+ if (commaIdx <= 0)
86
+ continue;
87
+ const version = token.slice(0, commaIdx);
88
+ const b64 = token.slice(commaIdx + 1);
89
+ if (version !== 'v1' || !b64)
90
+ continue;
91
+ const candidateBytes = Buffer.from(b64, 'base64');
92
+ if (candidateBytes.length === expectedMac.length && timingSafeEqual(candidateBytes, expectedMac)) {
93
+ matched = true;
94
+ }
95
+ }
96
+ if (!matched) {
97
+ throw new UlexWebhookVerificationError('signature_mismatch', 'Webhook HMAC-SHA256 signature verification failed.');
98
+ }
99
+ return JSON.parse(bodyStr);
100
+ }
101
+ export class UlexServerClient {
102
+ issuer;
103
+ oauth;
104
+ clientId;
105
+ clientSecret;
106
+ apiKey;
107
+ defaultAudience;
108
+ jwksTtlMs;
109
+ fetchImpl;
110
+ cachedJwks = null;
111
+ cachedM2M = new Map();
112
+ constructor(config) {
113
+ this.issuer = config.issuer.trim().replace(/\/+$/, '');
114
+ this.clientId = config.clientId;
115
+ this.clientSecret = config.clientSecret;
116
+ this.apiKey = config.apiKey;
117
+ this.defaultAudience = config.audience;
118
+ this.jwksTtlMs = config.jwksTtlMs ?? 300_000;
119
+ this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
120
+ this.oauth = new UlexClient({
121
+ issuer: this.issuer,
122
+ clientId: config.clientId,
123
+ fetch: this.fetchImpl,
124
+ });
125
+ }
126
+ /**
127
+ * Verifies a JWT access or ID token against the tenant's JWKS (`/oauth2/keys`),
128
+ * checking cryptographic signature, `iss`, `exp`, optional `aud`, and `requiredScopes`.
129
+ */
130
+ async verifyAccessToken(token, options = {}) {
131
+ const jwks = await this.getJwks(false);
132
+ const audience = options.audience ?? this.defaultAudience;
133
+ const verifyOpts = {
134
+ issuer: this.issuer,
135
+ clockTolerance: options.clockToleranceSeconds ?? 5,
136
+ ...(audience ? { audience: Array.isArray(audience) ? [...audience] : audience } : {}),
137
+ };
138
+ let payload;
139
+ try {
140
+ const verified = await jwtVerify(token, jwks, verifyOpts);
141
+ payload = verified.payload;
142
+ }
143
+ catch {
144
+ // Refresh JWKS once in case the tenant just rotated signing keys.
145
+ const refreshedJwks = await this.getJwks(true);
146
+ try {
147
+ const verified = await jwtVerify(token, refreshedJwks, verifyOpts);
148
+ payload = verified.payload;
149
+ }
150
+ catch (err) {
151
+ const msg = err instanceof Error ? err.message : 'JWT verification failed';
152
+ throw new UlexTokenVerificationError('invalid_token', msg);
153
+ }
154
+ }
155
+ if (!payload.sub) {
156
+ throw new UlexTokenVerificationError('missing_sub', 'Verified token is missing `sub` claim.');
157
+ }
158
+ const rawScope = typeof payload['scope'] === 'string' ? payload['scope'] : '';
159
+ const scopes = rawScope ? rawScope.split(/\s+/).filter(Boolean) : [];
160
+ if (options.requiredScopes?.length) {
161
+ const scopeSet = new Set(scopes);
162
+ const missing = options.requiredScopes.filter((s) => !scopeSet.has(s));
163
+ if (missing.length > 0) {
164
+ throw new UlexTokenVerificationError('insufficient_scope', `Token is missing required scope(s): ${missing.join(', ')}`);
165
+ }
166
+ }
167
+ return {
168
+ sub: payload.sub,
169
+ iss: payload.iss ?? this.issuer,
170
+ aud: payload.aud,
171
+ exp: payload.exp,
172
+ iat: payload.iat,
173
+ scopes,
174
+ orgId: typeof payload['org_id'] === 'string' ? payload['org_id'] : undefined,
175
+ clientId: typeof payload['client_id'] === 'string' ? payload['client_id'] : undefined,
176
+ claims: payload,
177
+ };
178
+ }
179
+ /**
180
+ * Acquires and caches an M2M `client_credentials` token, refreshing 30 seconds
181
+ * prior to expiration.
182
+ */
183
+ async getM2MToken(scopes = []) {
184
+ if (!this.clientId || !this.clientSecret) {
185
+ throw new UlexOAuthError('missing_client_credentials', 400, '`clientId` and `clientSecret` are required for `client_credentials` grant.');
186
+ }
187
+ const cacheKey = [...scopes].sort().join(' ');
188
+ const cached = this.cachedM2M.get(cacheKey);
189
+ if (cached && cached.expiresAt > Date.now()) {
190
+ return cached.token;
191
+ }
192
+ const body = new URLSearchParams({
193
+ grant_type: 'client_credentials',
194
+ client_id: this.clientId,
195
+ client_secret: this.clientSecret,
196
+ });
197
+ if (scopes.length > 0) {
198
+ body.set('scope', scopes.join(' '));
199
+ }
200
+ const res = await this.fetchImpl(`${this.issuer}/oauth2/token`, {
201
+ method: 'POST',
202
+ headers: {
203
+ 'content-type': 'application/x-www-form-urlencoded',
204
+ accept: 'application/json',
205
+ },
206
+ body: body.toString(),
207
+ });
208
+ if (!res.ok) {
209
+ const payload = (await res.json().catch(() => ({})));
210
+ const code = typeof payload['error'] === 'string' ? payload['error'] : `http_${res.status}`;
211
+ const desc = typeof payload['error_description'] === 'string' ? payload['error_description'] : undefined;
212
+ throw new UlexOAuthError(code, res.status, desc);
213
+ }
214
+ const token = (await res.json());
215
+ const ttlMs = Math.max(0, (token.expires_in - 30) * 1000);
216
+ this.cachedM2M.set(cacheKey, { token, expiresAt: Date.now() + ttlMs });
217
+ return token;
218
+ }
219
+ /**
220
+ * Server-authenticated RFC 8693 token exchange (`urn:ietf:params:oauth:grant-type:token-exchange`).
221
+ */
222
+ async exchangeToken(options) {
223
+ return this.oauth.exchangeToken({
224
+ ...options,
225
+ clientSecret: this.clientSecret,
226
+ });
227
+ }
228
+ async getUsage() {
229
+ return this.apiGet('/api/v1/usage');
230
+ }
231
+ async listUsers(limit = 50, cursor) {
232
+ const qs = new URLSearchParams({ limit: String(limit) });
233
+ if (cursor)
234
+ qs.set('cursor', cursor);
235
+ return this.apiGet(`/api/v1/users?${qs.toString()}`);
236
+ }
237
+ async listOrganizations(limit = 50, cursor) {
238
+ const qs = new URLSearchParams({ limit: String(limit) });
239
+ if (cursor)
240
+ qs.set('cursor', cursor);
241
+ return this.apiGet(`/api/v1/organizations?${qs.toString()}`);
242
+ }
243
+ async apiGet(path) {
244
+ if (!this.apiKey) {
245
+ throw new UlexOAuthError('missing_api_key', 401, 'Management API calls require `apiKey`.');
246
+ }
247
+ const baseOrigin = new URL(this.issuer).origin;
248
+ const res = await this.fetchImpl(`${baseOrigin}${path}`, {
249
+ method: 'GET',
250
+ headers: {
251
+ authorization: `Bearer ${this.apiKey}`,
252
+ accept: 'application/json',
253
+ },
254
+ });
255
+ if (!res.ok) {
256
+ const payload = (await res.json().catch(() => ({})));
257
+ const nested = typeof payload['error'] === 'object' && payload['error'] !== null
258
+ ? payload['error']
259
+ : null;
260
+ const code = typeof nested?.['code'] === 'string' ? nested['code'] : `http_${res.status}`;
261
+ const msg = typeof nested?.['message'] === 'string' ? nested['message'] : undefined;
262
+ throw new UlexOAuthError(code, res.status, msg);
263
+ }
264
+ return (await res.json());
265
+ }
266
+ async getJwks(forceRefresh) {
267
+ if (!forceRefresh && this.cachedJwks && this.cachedJwks.expiresAt > Date.now()) {
268
+ return this.cachedJwks.set;
269
+ }
270
+ const res = await this.fetchImpl(`${this.issuer}/oauth2/keys`, {
271
+ method: 'GET',
272
+ headers: { accept: 'application/json' },
273
+ });
274
+ if (!res.ok) {
275
+ throw new UlexTokenVerificationError('jwks_fetch_failed', `Failed to fetch JWKS from ${this.issuer}/oauth2/keys (HTTP ${res.status}).`);
276
+ }
277
+ const jwksJson = (await res.json());
278
+ const set = createLocalJWKSet(jwksJson);
279
+ this.cachedJwks = { set, expiresAt: Date.now() + this.jwksTtlMs };
280
+ return set;
281
+ }
282
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@ulexid/node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "description": "Official server-side Node.js and Edge SDK for Ulexid: JWKS JWT verification, M2M client credentials, RFC 8693 agent token exchange, Svix-compatible webhook verification, and management API client.",
7
+ "homepage": "https://www.ulexid.com",
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ },
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "dependencies": {
27
+ "@ulexid/js": "0.1.1",
28
+ "jose": "^5.9.6"
29
+ }
30
+ }