@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/src/client.ts ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Core HTTP client for Hanzo IAM (Casdoor) API.
3
+ */
4
+
5
+ import type {
6
+ IamConfig,
7
+ IamApiResponse,
8
+ IamUser,
9
+ IamOrganization,
10
+ OidcDiscovery,
11
+ TokenResponse,
12
+ } from "./types.js";
13
+
14
+ const DEFAULT_TIMEOUT_MS = 10_000;
15
+
16
+ export class IamClient {
17
+ private readonly baseUrl: string;
18
+ private readonly clientId: string;
19
+ private readonly clientSecret: string | undefined;
20
+ private readonly orgName: string | undefined;
21
+ private readonly appName: string | undefined;
22
+ private discoveryCache: { data: OidcDiscovery; fetchedAt: number } | null = null;
23
+
24
+ constructor(config: IamConfig) {
25
+ this.baseUrl = config.serverUrl.replace(/\/+$/, "");
26
+ this.clientId = config.clientId;
27
+ this.clientSecret = config.clientSecret;
28
+ this.orgName = config.orgName;
29
+ this.appName = config.appName;
30
+ }
31
+
32
+ // -----------------------------------------------------------------------
33
+ // Internal HTTP helpers
34
+ // -----------------------------------------------------------------------
35
+
36
+ private async request<T>(
37
+ path: string,
38
+ opts?: {
39
+ method?: string;
40
+ body?: unknown;
41
+ token?: string;
42
+ params?: Record<string, string>;
43
+ timeoutMs?: number;
44
+ },
45
+ ): Promise<T> {
46
+ const url = new URL(path, this.baseUrl);
47
+ if (opts?.params) {
48
+ for (const [k, v] of Object.entries(opts.params)) {
49
+ url.searchParams.set(k, v);
50
+ }
51
+ }
52
+
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(
55
+ () => controller.abort(),
56
+ opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
57
+ );
58
+
59
+ const headers: Record<string, string> = {
60
+ Accept: "application/json",
61
+ };
62
+ if (opts?.token) {
63
+ headers.Authorization = `Bearer ${opts.token}`;
64
+ }
65
+ if (opts?.body) {
66
+ headers["Content-Type"] = "application/json";
67
+ }
68
+
69
+ // Server-side basic auth for confidential client operations
70
+ if (this.clientSecret && !opts?.token) {
71
+ const credentials = `${this.clientId}:${this.clientSecret}`;
72
+ const basic =
73
+ typeof Buffer !== "undefined"
74
+ ? Buffer.from(credentials).toString("base64")
75
+ : btoa(credentials);
76
+ headers.Authorization = `Basic ${basic}`;
77
+ }
78
+
79
+ try {
80
+ const res = await fetch(url.toString(), {
81
+ method: opts?.method ?? "GET",
82
+ headers,
83
+ body: opts?.body ? JSON.stringify(opts.body) : undefined,
84
+ signal: controller.signal,
85
+ });
86
+
87
+ if (!res.ok) {
88
+ const text = await res.text().catch(() => "");
89
+ throw new IamApiError(res.status, `${res.statusText}: ${text}`.trim());
90
+ }
91
+
92
+ return (await res.json()) as T;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+
98
+ // -----------------------------------------------------------------------
99
+ // OIDC Discovery
100
+ // -----------------------------------------------------------------------
101
+
102
+ async getDiscovery(): Promise<OidcDiscovery> {
103
+ const CACHE_TTL_MS = 5 * 60 * 1000;
104
+ if (this.discoveryCache && Date.now() - this.discoveryCache.fetchedAt < CACHE_TTL_MS) {
105
+ return this.discoveryCache.data;
106
+ }
107
+ const data = await this.request<OidcDiscovery>(
108
+ "/.well-known/openid-configuration",
109
+ );
110
+ this.discoveryCache = { data, fetchedAt: Date.now() };
111
+ return data;
112
+ }
113
+
114
+ /** Get JWKS URI from OIDC discovery (cached). */
115
+ async getJwksUri(): Promise<string> {
116
+ const discovery = await this.getDiscovery();
117
+ return discovery.jwks_uri;
118
+ }
119
+
120
+ // -----------------------------------------------------------------------
121
+ // OAuth2 / Token
122
+ // -----------------------------------------------------------------------
123
+
124
+ /** Build the authorization URL for user login redirect. */
125
+ async getAuthorizationUrl(params: {
126
+ redirectUri: string;
127
+ state: string;
128
+ scope?: string;
129
+ codeChallenge?: string;
130
+ codeChallengeMethod?: string;
131
+ }): Promise<string> {
132
+ const discovery = await this.getDiscovery();
133
+ const url = new URL(discovery.authorization_endpoint);
134
+ url.searchParams.set("client_id", this.clientId);
135
+ url.searchParams.set("response_type", "code");
136
+ url.searchParams.set("redirect_uri", params.redirectUri);
137
+ url.searchParams.set("state", params.state);
138
+ url.searchParams.set("scope", params.scope ?? "openid profile email");
139
+ if (params.codeChallenge) {
140
+ url.searchParams.set("code_challenge", params.codeChallenge);
141
+ url.searchParams.set("code_challenge_method", params.codeChallengeMethod ?? "S256");
142
+ }
143
+ return url.toString();
144
+ }
145
+
146
+ /** Exchange authorization code for tokens. */
147
+ async exchangeCode(params: {
148
+ code: string;
149
+ redirectUri: string;
150
+ codeVerifier?: string;
151
+ }): Promise<TokenResponse> {
152
+ const discovery = await this.getDiscovery();
153
+ const body = new URLSearchParams({
154
+ grant_type: "authorization_code",
155
+ client_id: this.clientId,
156
+ code: params.code,
157
+ redirect_uri: params.redirectUri,
158
+ });
159
+ if (this.clientSecret) {
160
+ body.set("client_secret", this.clientSecret);
161
+ }
162
+ if (params.codeVerifier) {
163
+ body.set("code_verifier", params.codeVerifier);
164
+ }
165
+
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
168
+ try {
169
+ const res = await fetch(discovery.token_endpoint, {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
172
+ body: body.toString(),
173
+ signal: controller.signal,
174
+ });
175
+ if (!res.ok) {
176
+ const text = await res.text().catch(() => "");
177
+ throw new IamApiError(res.status, `Token exchange failed: ${text}`);
178
+ }
179
+ return (await res.json()) as TokenResponse;
180
+ } finally {
181
+ clearTimeout(timer);
182
+ }
183
+ }
184
+
185
+ /** Refresh an access token. */
186
+ async refreshToken(refreshToken: string): Promise<TokenResponse> {
187
+ const discovery = await this.getDiscovery();
188
+ const body = new URLSearchParams({
189
+ grant_type: "refresh_token",
190
+ client_id: this.clientId,
191
+ refresh_token: refreshToken,
192
+ });
193
+ if (this.clientSecret) {
194
+ body.set("client_secret", this.clientSecret);
195
+ }
196
+
197
+ const controller = new AbortController();
198
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
199
+ try {
200
+ const res = await fetch(discovery.token_endpoint, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
203
+ body: body.toString(),
204
+ signal: controller.signal,
205
+ });
206
+ if (!res.ok) {
207
+ const text = await res.text().catch(() => "");
208
+ throw new IamApiError(res.status, `Token refresh failed: ${text}`);
209
+ }
210
+ return (await res.json()) as TokenResponse;
211
+ } finally {
212
+ clearTimeout(timer);
213
+ }
214
+ }
215
+
216
+ // -----------------------------------------------------------------------
217
+ // User
218
+ // -----------------------------------------------------------------------
219
+
220
+ /** Get user info from access token (OIDC userinfo endpoint). */
221
+ async getUserInfo(accessToken: string): Promise<IamUser> {
222
+ const discovery = await this.getDiscovery();
223
+ const controller = new AbortController();
224
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
225
+ try {
226
+ const res = await fetch(discovery.userinfo_endpoint, {
227
+ headers: { Authorization: `Bearer ${accessToken}` },
228
+ signal: controller.signal,
229
+ });
230
+ if (!res.ok) {
231
+ throw new IamApiError(res.status, "Failed to fetch userinfo");
232
+ }
233
+ return (await res.json()) as IamUser;
234
+ } finally {
235
+ clearTimeout(timer);
236
+ }
237
+ }
238
+
239
+ /** Get a user by ID ("org/username" format). */
240
+ async getUser(userId: string, token?: string): Promise<IamUser | null> {
241
+ const resp = await this.request<IamApiResponse<IamUser>>("/api/get-user", {
242
+ params: { id: userId },
243
+ token,
244
+ });
245
+ return resp.data ?? null;
246
+ }
247
+
248
+ // -----------------------------------------------------------------------
249
+ // Organization
250
+ // -----------------------------------------------------------------------
251
+
252
+ /** List organizations (for the configured owner). */
253
+ async getOrganizations(token?: string): Promise<IamOrganization[]> {
254
+ const owner = this.orgName ?? "admin";
255
+ const resp = await this.request<IamApiResponse<IamOrganization[]>>(
256
+ "/api/get-organizations",
257
+ { params: { owner }, token },
258
+ );
259
+ return resp.data ?? [];
260
+ }
261
+
262
+ /** Get a specific organization. */
263
+ async getOrganization(
264
+ id: string,
265
+ token?: string,
266
+ ): Promise<IamOrganization | null> {
267
+ const resp = await this.request<IamApiResponse<IamOrganization>>(
268
+ "/api/get-organization",
269
+ { params: { id }, token },
270
+ );
271
+ return resp.data ?? null;
272
+ }
273
+
274
+ /** Get organizations a user belongs to. */
275
+ async getUserOrganizations(
276
+ userId: string,
277
+ token?: string,
278
+ ): Promise<IamOrganization[]> {
279
+ // Casdoor returns orgs the user is a member of via the user's properties.
280
+ // We can also query via get-user and read their signupApplication/org.
281
+ const user = await this.getUser(userId, token);
282
+ if (!user) return [];
283
+ // The owner field on a user is their org
284
+ const org = await this.getOrganization(
285
+ `admin/${user.owner}`,
286
+ token,
287
+ );
288
+ return org ? [org] : [];
289
+ }
290
+
291
+ // -----------------------------------------------------------------------
292
+ // Raw request (for extending)
293
+ // -----------------------------------------------------------------------
294
+
295
+ /** Make an arbitrary authenticated request to the IAM API. */
296
+ async apiRequest<T = unknown>(
297
+ path: string,
298
+ opts?: { method?: string; body?: unknown; token?: string; params?: Record<string, string> },
299
+ ): Promise<T> {
300
+ return this.request<T>(path, opts);
301
+ }
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Error
306
+ // ---------------------------------------------------------------------------
307
+
308
+ export class IamApiError extends Error {
309
+ readonly status: number;
310
+
311
+ constructor(status: number, message: string) {
312
+ super(message);
313
+ this.name = "IamApiError";
314
+ this.status = status;
315
+ }
316
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @hanzo/iam — TypeScript SDK for Hanzo IAM (Casdoor-based identity & access management).
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { IamClient, IamBillingClient, validateToken } from "@hanzo/iam";
7
+ *
8
+ * const client = new IamClient({
9
+ * serverUrl: "https://iam.hanzo.ai",
10
+ * clientId: "my-app",
11
+ * });
12
+ *
13
+ * // Validate a JWT
14
+ * const result = await validateToken(accessToken, {
15
+ * serverUrl: "https://iam.hanzo.ai",
16
+ * clientId: "my-app",
17
+ * });
18
+ * ```
19
+ */
20
+
21
+ // Core client
22
+ export { IamClient, IamApiError } from "./client.js";
23
+
24
+ // JWT validation
25
+ export { validateToken, clearJwksCache } from "./auth.js";
26
+
27
+ // Billing client
28
+ export { IamBillingClient } from "./billing.js";
29
+
30
+ // Browser PKCE auth (re-exported from separate entry point too)
31
+ export { BrowserIamSdk, type BrowserIamConfig } from "./browser.js";
32
+ export { generatePkceChallenge, generateState } from "./pkce.js";
33
+
34
+ // React bindings — import from "@hanzo/iam/react" for tree-shaking:
35
+ // import { IamProvider, useIam, useOrganizations } from "@hanzo/iam/react"
36
+
37
+ // Types (re-export everything)
38
+ export type {
39
+ IamConfig,
40
+ OidcDiscovery,
41
+ TokenResponse,
42
+ IamJwtClaims,
43
+ IamUser,
44
+ IamOrganization,
45
+ IamSubscription,
46
+ IamPlan,
47
+ IamPricing,
48
+ IamPayment,
49
+ IamOrder,
50
+ IamAuthResult,
51
+ IamApiResponse,
52
+ } from "./types.js";
package/src/pkce.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * PKCE (Proof Key for Code Exchange) utilities for browser-side OAuth2 flows.
3
+ *
4
+ * Adapted from casdoor-js-sdk, modernized for native Web Crypto API.
5
+ */
6
+
7
+ function generateRandomString(length: number): string {
8
+ const array = new Uint8Array(length);
9
+ crypto.getRandomValues(array);
10
+ return Array.from(array, (b) => b.toString(36).padStart(2, "0"))
11
+ .join("")
12
+ .slice(0, length);
13
+ }
14
+
15
+ async function sha256(plain: string): Promise<ArrayBuffer> {
16
+ const encoder = new TextEncoder();
17
+ return crypto.subtle.digest("SHA-256", encoder.encode(plain));
18
+ }
19
+
20
+ function base64UrlEncode(buffer: ArrayBuffer): string {
21
+ const bytes = new Uint8Array(buffer);
22
+ let binary = "";
23
+ for (const byte of bytes) {
24
+ binary += String.fromCharCode(byte);
25
+ }
26
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
+ }
28
+
29
+ /** Generate a PKCE code verifier + challenge pair. */
30
+ export async function generatePkceChallenge(): Promise<{
31
+ codeVerifier: string;
32
+ codeChallenge: string;
33
+ }> {
34
+ const codeVerifier = generateRandomString(64);
35
+ const hash = await sha256(codeVerifier);
36
+ const codeChallenge = base64UrlEncode(hash);
37
+ return { codeVerifier, codeChallenge };
38
+ }
39
+
40
+ /** Generate a random state parameter for CSRF protection. */
41
+ export function generateState(): string {
42
+ return generateRandomString(32);
43
+ }