@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/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +130 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +41 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +154 -0
- package/dist/billing.js.map +1 -0
- package/dist/browser.d.ts +83 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +370 -0
- package/dist/browser.js.map +1 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +238 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/pkce.d.ts +13 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +36 -0
- package/dist/pkce.js.map +1 -0
- package/dist/react.d.ts +123 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +422 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +93 -0
- package/src/auth.ts +151 -0
- package/src/billing.ts +238 -0
- package/src/browser.ts +451 -0
- package/src/client.ts +316 -0
- package/src/index.ts +52 -0
- package/src/pkce.ts +43 -0
- package/src/react.ts +533 -0
- package/src/types.ts +221 -0
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
|
+
}
|