@hanzo/iam 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/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@hanzo/iam",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for Hanzo IAM — OIDC auth, JWT validation, OAuth2 PKCE, user/org/billing APIs",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./auth": {
14
+ "types": "./dist/auth.d.ts",
15
+ "import": "./dist/auth.js"
16
+ },
17
+ "./browser": {
18
+ "types": "./dist/browser.d.ts",
19
+ "import": "./dist/browser.js"
20
+ },
21
+ "./billing": {
22
+ "types": "./dist/billing.d.ts",
23
+ "import": "./dist/billing.js"
24
+ },
25
+ "./types": {
26
+ "types": "./dist/types.d.ts",
27
+ "import": "./dist/types.js"
28
+ },
29
+ "./react": {
30
+ "types": "./dist/react.d.ts",
31
+ "import": "./dist/react.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "dev": "tsc --watch",
43
+ "clean": "rm -rf dist",
44
+ "prepare": "npm run clean && npm run build",
45
+ "prepublishOnly": "npm run clean && npm run build",
46
+ "test": "node --test --import tsx src/**/*.test.ts"
47
+ },
48
+ "dependencies": {
49
+ "jose": "^6.1.0"
50
+ },
51
+ "peerDependencies": {
52
+ "react": ">=17"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "react": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^22.19.11",
61
+ "@types/react": "^19.0.0",
62
+ "typescript": "^5.5.0"
63
+ },
64
+ "keywords": [
65
+ "hanzo",
66
+ "iam",
67
+ "casdoor",
68
+ "oidc",
69
+ "oauth2",
70
+ "pkce",
71
+ "auth",
72
+ "jwt",
73
+ "identity",
74
+ "access-management",
75
+ "sso"
76
+ ],
77
+ "license": "MIT",
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "https://github.com/hanzo-js/iam"
81
+ },
82
+ "homepage": "https://docs.hanzo.ai/services/iam/sdk",
83
+ "bugs": {
84
+ "url": "https://github.com/hanzo-js/iam/issues"
85
+ },
86
+ "author": "Hanzo AI <engineering@hanzo.ai>",
87
+ "publishConfig": {
88
+ "access": "public"
89
+ },
90
+ "engines": {
91
+ "node": ">=18"
92
+ }
93
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * JWT validation using jose library + OIDC JWKS discovery.
3
+ *
4
+ * Validates access/ID tokens issued by Hanzo IAM (Casdoor).
5
+ */
6
+
7
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
8
+ import type { IamConfig, IamAuthResult, IamJwtClaims } from "./types.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // JWKS key set cache (per issuer)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const jwksSets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
15
+
16
+ function getJwksKeySet(jwksUri: string): ReturnType<typeof createRemoteJWKSet> {
17
+ let keySet = jwksSets.get(jwksUri);
18
+ if (!keySet) {
19
+ keySet = createRemoteJWKSet(new URL(jwksUri));
20
+ jwksSets.set(jwksUri, keySet);
21
+ }
22
+ return keySet;
23
+ }
24
+
25
+ /** Clear cached JWKS key sets (useful for testing or key rotation). */
26
+ export function clearJwksCache(): void {
27
+ jwksSets.clear();
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // OIDC discovery cache (lightweight, no full client needed)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ type CachedDiscovery = { jwksUri: string; issuer: string; fetchedAt: number };
35
+ const discoveryCache = new Map<string, CachedDiscovery>();
36
+ const DISCOVERY_TTL_MS = 5 * 60 * 1000;
37
+
38
+ async function resolveJwksUri(serverUrl: string): Promise<{ jwksUri: string; issuer: string }> {
39
+ const baseUrl = serverUrl.replace(/\/+$/, "");
40
+ const cached = discoveryCache.get(baseUrl);
41
+ if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {
42
+ return { jwksUri: cached.jwksUri, issuer: cached.issuer };
43
+ }
44
+
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), 8_000);
47
+ try {
48
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
49
+ signal: controller.signal,
50
+ headers: { Accept: "application/json" },
51
+ });
52
+ if (!res.ok) {
53
+ throw new Error(`OIDC discovery failed: ${res.status}`);
54
+ }
55
+ const body = (await res.json()) as { jwks_uri?: string; issuer?: string };
56
+ const jwksUri = body.jwks_uri;
57
+ const issuer = body.issuer ?? baseUrl;
58
+ if (!jwksUri) {
59
+ throw new Error("OIDC discovery response missing jwks_uri");
60
+ }
61
+ discoveryCache.set(baseUrl, { jwksUri, issuer, fetchedAt: Date.now() });
62
+ return { jwksUri, issuer };
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Token validation
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Validate a JWT access token against IAM's JWKS.
74
+ *
75
+ * Uses OIDC discovery to find the JWKS URI, then verifies the token
76
+ * signature, issuer, audience, and expiry using the `jose` library.
77
+ */
78
+ export async function validateToken(
79
+ token: string,
80
+ config: IamConfig,
81
+ ): Promise<IamAuthResult> {
82
+ if (!token || typeof token !== "string") {
83
+ return { ok: false, reason: "iam_token_missing" };
84
+ }
85
+
86
+ let jwksUri: string;
87
+ let issuer: string;
88
+ try {
89
+ const discovery = await resolveJwksUri(config.serverUrl);
90
+ jwksUri = discovery.jwksUri;
91
+ issuer = discovery.issuer;
92
+ } catch {
93
+ return { ok: false, reason: "iam_discovery_failed" };
94
+ }
95
+
96
+ const keySet = getJwksKeySet(jwksUri);
97
+
98
+ let payload: JWTPayload;
99
+ try {
100
+ const result = await jwtVerify(token, keySet, {
101
+ issuer,
102
+ audience: config.clientId,
103
+ clockTolerance: 30, // 30s clock skew
104
+ });
105
+ payload = result.payload;
106
+ } catch (err) {
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ if (message.includes("expired")) {
109
+ return { ok: false, reason: "iam_token_expired" };
110
+ }
111
+ if (message.includes("audience")) {
112
+ // Retry without audience check - some Casdoor configs don't set aud
113
+ try {
114
+ const result = await jwtVerify(token, keySet, {
115
+ issuer,
116
+ clockTolerance: 30,
117
+ });
118
+ payload = result.payload;
119
+ } catch {
120
+ return { ok: false, reason: "iam_signature_invalid" };
121
+ }
122
+ } else {
123
+ return { ok: false, reason: "iam_signature_invalid" };
124
+ }
125
+ }
126
+
127
+ const claims = payload as unknown as IamJwtClaims;
128
+
129
+ if (!claims.sub) {
130
+ return { ok: false, reason: "iam_subject_missing" };
131
+ }
132
+
133
+ // Casdoor sub format is "org/username" - extract owner
134
+ const parts = claims.sub.split("/");
135
+ const owner = parts.length > 1 ? parts[0] : config.orgName ?? "unknown";
136
+
137
+ return {
138
+ ok: true,
139
+ userId: claims.sub,
140
+ email: typeof claims.email === "string" ? claims.email : undefined,
141
+ name:
142
+ typeof claims.name === "string"
143
+ ? claims.name
144
+ : typeof claims.preferred_username === "string"
145
+ ? claims.preferred_username
146
+ : undefined,
147
+ avatar: typeof claims.picture === "string" ? claims.picture : undefined,
148
+ owner,
149
+ claims,
150
+ };
151
+ }
package/src/billing.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Billing client for Hanzo IAM (Casdoor) — subscriptions, plans, pricing, usage.
3
+ */
4
+
5
+ import type {
6
+ IamConfig,
7
+ IamSubscription,
8
+ IamPlan,
9
+ IamPricing,
10
+ IamPayment,
11
+ IamOrder,
12
+ IamApiResponse,
13
+ } from "./types.js";
14
+
15
+ const DEFAULT_TIMEOUT_MS = 10_000;
16
+
17
+ export class IamBillingClient {
18
+ private readonly baseUrl: string;
19
+ private readonly clientId: string;
20
+ private readonly clientSecret: string | undefined;
21
+ private readonly orgName: string | undefined;
22
+
23
+ constructor(config: IamConfig) {
24
+ this.baseUrl = config.serverUrl.replace(/\/+$/, "");
25
+ this.clientId = config.clientId;
26
+ this.clientSecret = config.clientSecret;
27
+ this.orgName = config.orgName;
28
+ }
29
+
30
+ // -----------------------------------------------------------------------
31
+ // Internal HTTP helper
32
+ // -----------------------------------------------------------------------
33
+
34
+ private async request<T>(
35
+ path: string,
36
+ opts?: {
37
+ method?: string;
38
+ body?: unknown;
39
+ token?: string;
40
+ params?: Record<string, string>;
41
+ },
42
+ ): Promise<T> {
43
+ const url = new URL(path, this.baseUrl);
44
+ if (opts?.params) {
45
+ for (const [k, v] of Object.entries(opts.params)) {
46
+ url.searchParams.set(k, v);
47
+ }
48
+ }
49
+
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
52
+
53
+ const headers: Record<string, string> = {
54
+ Accept: "application/json",
55
+ };
56
+ if (opts?.token) {
57
+ headers.Authorization = `Bearer ${opts.token}`;
58
+ }
59
+ if (opts?.body) {
60
+ headers["Content-Type"] = "application/json";
61
+ }
62
+ if (this.clientSecret && !opts?.token) {
63
+ const credentials = `${this.clientId}:${this.clientSecret}`;
64
+ const basic =
65
+ typeof Buffer !== "undefined"
66
+ ? Buffer.from(credentials).toString("base64")
67
+ : btoa(credentials);
68
+ headers.Authorization = `Basic ${basic}`;
69
+ }
70
+
71
+ try {
72
+ const res = await fetch(url.toString(), {
73
+ method: opts?.method ?? "GET",
74
+ headers,
75
+ body: opts?.body ? JSON.stringify(opts.body) : undefined,
76
+ signal: controller.signal,
77
+ });
78
+
79
+ if (!res.ok) {
80
+ const text = await res.text().catch(() => "");
81
+ throw new Error(`IAM billing request failed (${res.status}): ${text}`.trim());
82
+ }
83
+
84
+ return (await res.json()) as T;
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
88
+ }
89
+
90
+ // -----------------------------------------------------------------------
91
+ // Subscriptions
92
+ // -----------------------------------------------------------------------
93
+
94
+ /** Get all subscriptions for an owner. */
95
+ async getSubscriptions(token?: string): Promise<IamSubscription[]> {
96
+ const owner = this.orgName ?? "admin";
97
+ const resp = await this.request<IamApiResponse<IamSubscription[]>>(
98
+ "/api/get-subscriptions",
99
+ { params: { owner }, token },
100
+ );
101
+ return resp.data ?? [];
102
+ }
103
+
104
+ /** Get a specific subscription by ID ("owner/name" format). */
105
+ async getSubscription(id: string, token?: string): Promise<IamSubscription | null> {
106
+ const resp = await this.request<IamApiResponse<IamSubscription>>(
107
+ "/api/get-subscription",
108
+ { params: { id }, token },
109
+ );
110
+ return resp.data ?? null;
111
+ }
112
+
113
+ /** Get the subscription for a specific user. */
114
+ async getUserSubscription(userId: string, token?: string): Promise<IamSubscription | null> {
115
+ const subs = await this.getSubscriptions(token);
116
+ return subs.find((s) => s.user === userId) ?? null;
117
+ }
118
+
119
+ // -----------------------------------------------------------------------
120
+ // Plans
121
+ // -----------------------------------------------------------------------
122
+
123
+ /** Get all plans for an owner. */
124
+ async getPlans(token?: string): Promise<IamPlan[]> {
125
+ const owner = this.orgName ?? "admin";
126
+ const resp = await this.request<IamApiResponse<IamPlan[]>>(
127
+ "/api/get-plans",
128
+ { params: { owner }, token },
129
+ );
130
+ return resp.data ?? [];
131
+ }
132
+
133
+ /** Get a specific plan by ID. */
134
+ async getPlan(id: string, token?: string): Promise<IamPlan | null> {
135
+ const resp = await this.request<IamApiResponse<IamPlan>>(
136
+ "/api/get-plan",
137
+ { params: { id }, token },
138
+ );
139
+ return resp.data ?? null;
140
+ }
141
+
142
+ // -----------------------------------------------------------------------
143
+ // Pricing
144
+ // -----------------------------------------------------------------------
145
+
146
+ /** Get all pricing configurations for an owner. */
147
+ async getPricings(token?: string): Promise<IamPricing[]> {
148
+ const owner = this.orgName ?? "admin";
149
+ const resp = await this.request<IamApiResponse<IamPricing[]>>(
150
+ "/api/get-pricings",
151
+ { params: { owner }, token },
152
+ );
153
+ return resp.data ?? [];
154
+ }
155
+
156
+ /** Get a specific pricing by ID. */
157
+ async getPricing(id: string, token?: string): Promise<IamPricing | null> {
158
+ const resp = await this.request<IamApiResponse<IamPricing>>(
159
+ "/api/get-pricing",
160
+ { params: { id }, token },
161
+ );
162
+ return resp.data ?? null;
163
+ }
164
+
165
+ // -----------------------------------------------------------------------
166
+ // Payments
167
+ // -----------------------------------------------------------------------
168
+
169
+ /** Get all payments for an owner. */
170
+ async getPayments(token?: string): Promise<IamPayment[]> {
171
+ const owner = this.orgName ?? "admin";
172
+ const resp = await this.request<IamApiResponse<IamPayment[]>>(
173
+ "/api/get-payments",
174
+ { params: { owner }, token },
175
+ );
176
+ return resp.data ?? [];
177
+ }
178
+
179
+ /** Get a specific payment by ID. */
180
+ async getPayment(id: string, token?: string): Promise<IamPayment | null> {
181
+ const resp = await this.request<IamApiResponse<IamPayment>>(
182
+ "/api/get-payment",
183
+ { params: { id }, token },
184
+ );
185
+ return resp.data ?? null;
186
+ }
187
+
188
+ // -----------------------------------------------------------------------
189
+ // Orders (if supported by IAM)
190
+ // -----------------------------------------------------------------------
191
+
192
+ /** Get all orders for an owner. */
193
+ async getOrders(token?: string): Promise<IamOrder[]> {
194
+ const owner = this.orgName ?? "admin";
195
+ const resp = await this.request<IamApiResponse<IamOrder[]>>(
196
+ "/api/get-orders",
197
+ { params: { owner }, token },
198
+ );
199
+ return resp.data ?? [];
200
+ }
201
+
202
+ /** Get a specific order by ID. */
203
+ async getOrder(id: string, token?: string): Promise<IamOrder | null> {
204
+ const resp = await this.request<IamApiResponse<IamOrder>>(
205
+ "/api/get-order",
206
+ { params: { id }, token },
207
+ );
208
+ return resp.data ?? null;
209
+ }
210
+
211
+ // -----------------------------------------------------------------------
212
+ // Convenience: check subscription status for an org
213
+ // -----------------------------------------------------------------------
214
+
215
+ /** Check if an org has an active subscription. */
216
+ async isSubscriptionActive(orgName: string, token?: string): Promise<{
217
+ active: boolean;
218
+ subscription: IamSubscription | null;
219
+ plan: IamPlan | null;
220
+ }> {
221
+ const subs = await this.getSubscriptions(token);
222
+ // Find subscription matching the org
223
+ const sub = subs.find(
224
+ (s) => s.owner === orgName && (s.state === "Active" || s.state === "active"),
225
+ ) ?? null;
226
+
227
+ if (!sub) {
228
+ return { active: false, subscription: null, plan: null };
229
+ }
230
+
231
+ let plan: IamPlan | null = null;
232
+ if (sub.plan) {
233
+ plan = await this.getPlan(sub.plan, token);
234
+ }
235
+
236
+ return { active: true, subscription: sub, plan };
237
+ }
238
+ }