@hanzo/iam 0.6.1 → 0.6.2

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Core HTTP client for Hanzo IAM (Casdoor) API.
2
+ * Core HTTP client for Hanzo IAM API.
3
3
  */
4
4
 
5
5
  import type {
@@ -7,7 +7,6 @@ import type {
7
7
  IamApiResponse,
8
8
  IamUser,
9
9
  IamOrganization,
10
- IamInvitation,
11
10
  IamProject,
12
11
  OidcDiscovery,
13
12
  TokenResponse,
@@ -184,6 +183,46 @@ export class IamClient {
184
183
  }
185
184
  }
186
185
 
186
+ /**
187
+ * Resource Owner Password Credentials grant.
188
+ * Used for service-to-service auth, CLI login, and e2e tests.
189
+ */
190
+ async passwordGrant(params: {
191
+ username: string;
192
+ password: string;
193
+ scope?: string;
194
+ }): Promise<TokenResponse> {
195
+ const discovery = await this.getDiscovery();
196
+ const body = new URLSearchParams({
197
+ grant_type: "password",
198
+ client_id: this.clientId,
199
+ username: params.username,
200
+ password: params.password,
201
+ scope: params.scope ?? "openid profile email phone",
202
+ });
203
+ if (this.clientSecret) {
204
+ body.set("client_secret", this.clientSecret);
205
+ }
206
+
207
+ const controller = new AbortController();
208
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
209
+ try {
210
+ const res = await fetch(discovery.token_endpoint, {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
213
+ body: body.toString(),
214
+ signal: controller.signal,
215
+ });
216
+ if (!res.ok) {
217
+ const text = await res.text().catch(() => "");
218
+ throw new IamApiError(res.status, `Password grant failed: ${text}`);
219
+ }
220
+ return (await res.json()) as TokenResponse;
221
+ } finally {
222
+ clearTimeout(timer);
223
+ }
224
+ }
225
+
187
226
  /** Refresh an access token. */
188
227
  async refreshToken(refreshToken: string): Promise<TokenResponse> {
189
228
  const discovery = await this.getDiscovery();
@@ -253,76 +292,12 @@ export class IamClient {
253
292
 
254
293
  /** List organizations (for the configured owner). */
255
294
  async getOrganizations(token?: string): Promise<IamOrganization[]> {
256
- // Build the user's org list from JWT claims.
257
- // The user's "owner" field is the signup org (used for auth only).
258
- // Their personal org (name == username) is their actual workspace.
259
- // Additional orgs come from invitations (future: membership table).
260
- const orgs: IamOrganization[] = [];
261
- const orgNames = new Set<string>();
262
-
263
- let signupOrg = "";
264
-
265
- if (token) {
266
- try {
267
- let b64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
268
- while (b64.length % 4) b64 += "=";
269
- const payload = JSON.parse(atob(b64));
270
- const userOwner = payload.owner as string;
271
- const userName = payload.name as string;
272
-
273
- signupOrg = userOwner;
274
- const isAdmin = !!payload.isAdmin;
275
-
276
- // Personal org (name == username) is the user's primary workspace.
277
- if (userName && userName !== userOwner) {
278
- orgs.push({
279
- owner: "admin",
280
- name: userName,
281
- displayName: userName,
282
- });
283
- orgNames.add(userName);
284
- }
285
-
286
- // Admin users also see their signup org (they manage it).
287
- // Non-admin users only see their personal org.
288
- if (isAdmin && userOwner && !orgNames.has(userOwner)) {
289
- orgs.push({ owner: "admin", name: userOwner, displayName: userOwner });
290
- orgNames.add(userOwner);
291
- }
292
-
293
- // If no personal org (username == owner), show the signup org as workspace
294
- if (!orgNames.size && userOwner) {
295
- orgs.push({ owner: "admin", name: userOwner, displayName: userOwner });
296
- orgNames.add(userOwner);
297
- }
298
- } catch {
299
- // JWT parse failed
300
- }
301
- }
302
-
303
- // Try the API to get additional orgs the user was invited to.
304
- // Exclude the signup org (it's just for auth, not a workspace).
305
295
  const owner = this.orgName ?? "admin";
306
- try {
307
- const resp = await this.request<IamApiResponse<IamOrganization[]>>(
308
- "/api/get-organizations",
309
- { params: { owner }, token },
310
- );
311
- if (resp.data) {
312
- for (const org of resp.data) {
313
- // Skip the signup org — it's not a user workspace
314
- if (org.name === signupOrg && orgNames.size > 0) continue;
315
- if (!orgNames.has(org.name)) {
316
- orgs.push(org);
317
- orgNames.add(org.name);
318
- }
319
- }
320
- }
321
- } catch {
322
- // API failed — JWT-derived orgs are sufficient
323
- }
324
-
325
- return orgs;
296
+ const resp = await this.request<IamApiResponse<IamOrganization[]>>(
297
+ "/api/get-organizations",
298
+ { params: { owner }, token },
299
+ );
300
+ return resp.data ?? [];
326
301
  }
327
302
 
328
303
  /** Get a specific organization. */
@@ -342,7 +317,7 @@ export class IamClient {
342
317
  userId: string,
343
318
  token?: string,
344
319
  ): Promise<IamOrganization[]> {
345
- // Casdoor returns orgs the user is a member of via the user's properties.
320
+ // IAM returns orgs the user is a member of via the user's properties.
346
321
  // We can also query via get-user and read their signupApplication/org.
347
322
  const user = await this.getUser(userId, token);
348
323
  if (!user) return [];
@@ -354,88 +329,6 @@ export class IamClient {
354
329
  return org ? [org] : [];
355
330
  }
356
331
 
357
- /** Create a new organization. */
358
- async createOrganization(
359
- org: Partial<IamOrganization>,
360
- token?: string,
361
- ): Promise<IamApiResponse<IamOrganization>> {
362
- return this.request<IamApiResponse<IamOrganization>>(
363
- "/api/add-organization",
364
- { method: "POST", body: org, token },
365
- );
366
- }
367
-
368
- /** Update an existing organization. */
369
- async updateOrganization(
370
- org: Partial<IamOrganization>,
371
- token?: string,
372
- ): Promise<IamApiResponse<IamOrganization>> {
373
- return this.request<IamApiResponse<IamOrganization>>(
374
- "/api/update-organization",
375
- { method: "POST", body: org, token },
376
- );
377
- }
378
-
379
- /** Delete an organization by owner and name. */
380
- async deleteOrganization(
381
- org: { owner: string; name: string },
382
- token?: string,
383
- ): Promise<IamApiResponse<IamOrganization>> {
384
- return this.request<IamApiResponse<IamOrganization>>(
385
- "/api/delete-organization",
386
- { method: "POST", body: org, token },
387
- );
388
- }
389
-
390
- // -----------------------------------------------------------------------
391
- // Invitation
392
- // -----------------------------------------------------------------------
393
-
394
- /** List invitations for an owner (organization). */
395
- async getInvitations(
396
- owner: string,
397
- token?: string,
398
- ): Promise<IamInvitation[]> {
399
- const resp = await this.request<IamApiResponse<IamInvitation[]>>(
400
- "/api/get-invitations",
401
- { params: { owner }, token },
402
- );
403
- return resp.data ?? [];
404
- }
405
-
406
- /** Create a new invitation. */
407
- async createInvitation(
408
- invitation: Partial<IamInvitation>,
409
- token?: string,
410
- ): Promise<IamApiResponse<IamInvitation>> {
411
- return this.request<IamApiResponse<IamInvitation>>(
412
- "/api/add-invitation",
413
- { method: "POST", body: invitation, token },
414
- );
415
- }
416
-
417
- /** Send an invitation by owner and name. */
418
- async sendInvitation(
419
- invitation: { owner: string; name: string },
420
- token?: string,
421
- ): Promise<IamApiResponse<IamInvitation>> {
422
- return this.request<IamApiResponse<IamInvitation>>(
423
- "/api/send-invitation",
424
- { method: "POST", body: invitation, token },
425
- );
426
- }
427
-
428
- /** Verify an invitation code. */
429
- async verifyInvitation(
430
- code: string,
431
- token?: string,
432
- ): Promise<IamApiResponse<IamInvitation>> {
433
- return this.request<IamApiResponse<IamInvitation>>(
434
- "/api/verify-invitation",
435
- { params: { code }, token },
436
- );
437
- }
438
-
439
332
  // -----------------------------------------------------------------------
440
333
  // Project
441
334
  // -----------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * });
15
15
  *
16
16
  * const billing = new BillingClient({
17
- * commerceUrl: "https://commerce-api.hanzo.ai/api",
17
+ * commerceUrl: "https://commerce.hanzo.ai",
18
18
  * });
19
19
  * ```
20
20
  */
@@ -43,7 +43,6 @@ export type {
43
43
  IamJwtClaims,
44
44
  IamUser,
45
45
  IamOrganization,
46
- IamInvitation,
47
46
  IamProject,
48
47
  Subscription,
49
48
  Plan,
package/src/nextauth.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
- * NextAuth.js provider for Hanzo IAM (OIDC-based).
2
+ * NextAuth.js / Auth.js provider for IAM (OIDC-based).
3
3
  *
4
- * Consolidates the HanzoIamProvider and IamProvider implementations
5
- * so all Next.js apps can share one canonical implementation.
4
+ * Provides a canonical NextAuth/Auth.js provider configuration
5
+ * so all Next.js apps can share one implementation.
6
6
  *
7
7
  * @example
8
8
  * ```ts
9
9
  * // next-auth config
10
- * import { HanzoIamProvider } from "@hanzo/iam/nextauth";
10
+ * import { IamProvider } from "@hanzo/iam/nextauth";
11
11
  *
12
12
  * export default NextAuth({
13
13
  * providers: [
14
- * HanzoIamProvider({
14
+ * IamProvider({
15
15
  * serverUrl: process.env.IAM_SERVER_URL!,
16
16
  * clientId: process.env.IAM_CLIENT_ID!,
17
17
  * clientSecret: process.env.IAM_CLIENT_SECRET!,
@@ -23,7 +23,7 @@
23
23
  * @packageDocumentation
24
24
  */
25
25
 
26
- interface HanzoIamProfile extends Record<string, unknown> {
26
+ export interface IamProfile extends Record<string, unknown> {
27
27
  sub: string;
28
28
  name: string;
29
29
  email: string;
@@ -35,7 +35,7 @@ interface HanzoIamProfile extends Record<string, unknown> {
35
35
  }
36
36
 
37
37
  /**
38
- * NextAuth.js / Auth.js compatible OAuth provider for Hanzo IAM.
38
+ * NextAuth.js / Auth.js compatible OAuth provider for IAM.
39
39
  *
40
40
  * Uses standard OIDC well-known endpoint for automatic configuration.
41
41
  * JWT id_token validation (issuer, audience, signature) is handled by
@@ -43,7 +43,7 @@ interface HanzoIamProfile extends Record<string, unknown> {
43
43
  *
44
44
  * Pass `checks: ["state", "pkce"]` in options for PKCE alignment.
45
45
  */
46
- export function HanzoIamProvider<P extends HanzoIamProfile>(
46
+ export function IamProvider<P extends IamProfile>(
47
47
  options: {
48
48
  serverUrl: string;
49
49
  clientId: string;
@@ -59,8 +59,8 @@ export function HanzoIamProvider<P extends HanzoIamProfile>(
59
59
  const checks = options.checks ?? ["state"];
60
60
 
61
61
  return {
62
- id: "hanzo-iam",
63
- name: "Hanzo IAM",
62
+ id: "iam",
63
+ name: "IAM",
64
64
  type: "oauth",
65
65
  wellKnown: `${issuer}/.well-known/openid-configuration`,
66
66
  idToken: true,
@@ -88,6 +88,8 @@ export function HanzoIamProvider<P extends HanzoIamProfile>(
88
88
  };
89
89
  }
90
90
 
91
- // Re-export with alias for backwards compat
92
- export { HanzoIamProvider as IamProvider };
93
- export type { HanzoIamProfile };
91
+ // Backwards-compatible aliases
92
+ /** @deprecated Use IamProvider instead */
93
+ export { IamProvider as HanzoIamProvider };
94
+ /** @deprecated Use IamProfile instead */
95
+ export type { IamProfile as HanzoIamProfile };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Passport.js OAuth2 strategy factory for Hanzo IAM.
3
+ *
4
+ * Creates a pre-configured passport-oauth2 strategy that authenticates
5
+ * against hanzo.id with PKCE and fetches user info on callback.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import passport from "passport";
10
+ * import { createIamPassportStrategy } from "@hanzo/iam/passport";
11
+ *
12
+ * passport.use("iam", createIamPassportStrategy({
13
+ * serverUrl: "https://hanzo.id",
14
+ * clientId: "hanzo-kms-client-id",
15
+ * clientSecret: process.env.IAM_CLIENT_SECRET!,
16
+ * callbackUrl: "https://kms.hanzo.ai/api/v1/sso/oidc/callback",
17
+ * }));
18
+ * ```
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import type { IamConfig } from "./types.js";
24
+
25
+ export interface IamPassportConfig extends IamConfig {
26
+ /** Full callback URL for OAuth2 redirect. */
27
+ callbackUrl: string;
28
+ /** OAuth2 scopes. Default: "openid profile email". */
29
+ scope?: string;
30
+ }
31
+
32
+ export interface IamPassportUser {
33
+ accessToken: string;
34
+ refreshToken?: string;
35
+ userinfo: Record<string, unknown>;
36
+ }
37
+
38
+ /**
39
+ * Create a Passport OAuth2 strategy for Hanzo IAM.
40
+ *
41
+ * Requires `passport-oauth2` as a peer dependency.
42
+ * Returns an OAuth2Strategy instance ready to pass to `passport.use()`.
43
+ *
44
+ * The verify callback fetches userinfo from the IAM server and passes
45
+ * `{ accessToken, refreshToken, userinfo }` as the user object.
46
+ */
47
+ export function createIamPassportStrategy(
48
+ config: IamPassportConfig,
49
+ ): unknown {
50
+ // Dynamic import to keep passport-oauth2 as optional peer dep.
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const { Strategy: OAuth2Strategy } = require("passport-oauth2") as {
53
+ Strategy: new (
54
+ options: Record<string, unknown>,
55
+ verify: (...args: unknown[]) => void,
56
+ ) => unknown;
57
+ };
58
+
59
+ const baseUrl = config.serverUrl.replace(/\/+$/, "");
60
+
61
+ const verify = async (
62
+ ...args: unknown[]
63
+ ): Promise<void> => {
64
+ // passReqToCallback=true: (req, accessToken, refreshToken, profile, done)
65
+ const accessToken = args[1] as string;
66
+ const refreshToken = args[2] as string | undefined;
67
+ const done = args[4] as (err: Error | null, user?: IamPassportUser) => void;
68
+
69
+ try {
70
+ const res = await fetch(`${baseUrl}/oauth/userinfo`, {
71
+ headers: { Authorization: `Bearer ${accessToken}` },
72
+ });
73
+ if (!res.ok) {
74
+ return done(new Error(`IAM userinfo failed: ${res.status}`));
75
+ }
76
+ const userinfo = (await res.json()) as Record<string, unknown>;
77
+ done(null, { accessToken, refreshToken, userinfo });
78
+ } catch (err) {
79
+ done(err instanceof Error ? err : new Error(String(err)));
80
+ }
81
+ };
82
+
83
+ return new OAuth2Strategy(
84
+ {
85
+ authorizationURL: `${baseUrl}/oauth/authorize`,
86
+ tokenURL: `${baseUrl}/oauth/token`,
87
+ clientID: config.clientId,
88
+ clientSecret: config.clientSecret ?? "",
89
+ callbackURL: config.callbackUrl,
90
+ scope: config.scope ?? "openid profile email",
91
+ state: true,
92
+ pkce: true,
93
+ passReqToCallback: true,
94
+ },
95
+ verify,
96
+ );
97
+ }
package/src/pkce.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PKCE (Proof Key for Code Exchange) utilities for browser-side OAuth2 flows.
3
3
  *
4
- * Adapted from casdoor-js-sdk, modernized for native Web Crypto API.
4
+ * PKCE utilities for OAuth2 flows, using native Web Crypto API.
5
5
  */
6
6
 
7
7
  function generateRandomString(length: number): string {