@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/browser.ts ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Browser-side OAuth2 flows for Hanzo IAM.
3
+ *
4
+ * Provides PKCE-based login redirect, code exchange, token refresh,
5
+ * popup signin, and silent signin for single-page applications.
6
+ *
7
+ * Adapted and modernized from casdoor-js-sdk.
8
+ */
9
+
10
+ import type { IamConfig, TokenResponse, OidcDiscovery } from "./types.js";
11
+ import { generatePkceChallenge, generateState } from "./pkce.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Storage keys
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const STORAGE_PREFIX = "hanzo_iam_";
18
+ const KEY_STATE = `${STORAGE_PREFIX}state`;
19
+ const KEY_CODE_VERIFIER = `${STORAGE_PREFIX}code_verifier`;
20
+ const KEY_ACCESS_TOKEN = `${STORAGE_PREFIX}access_token`;
21
+ const KEY_REFRESH_TOKEN = `${STORAGE_PREFIX}refresh_token`;
22
+ const KEY_ID_TOKEN = `${STORAGE_PREFIX}id_token`;
23
+ const KEY_EXPIRES_AT = `${STORAGE_PREFIX}expires_at`;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Browser IAM SDK
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type BrowserIamConfig = IamConfig & {
30
+ /** OAuth2 redirect URI (e.g. "https://app.hanzo.bot/auth/callback"). */
31
+ redirectUri: string;
32
+ /** OAuth2 scopes (default: "openid profile email"). */
33
+ scope?: string;
34
+ /** Storage to use for tokens (default: sessionStorage). */
35
+ storage?: Storage;
36
+ };
37
+
38
+ export class BrowserIamSdk {
39
+ private readonly config: BrowserIamConfig;
40
+ private readonly storage: Storage;
41
+ private discoveryCache: OidcDiscovery | null = null;
42
+
43
+ constructor(config: BrowserIamConfig) {
44
+ this.config = config;
45
+ this.storage = config.storage ?? sessionStorage;
46
+ }
47
+
48
+ // -----------------------------------------------------------------------
49
+ // OIDC Discovery
50
+ // -----------------------------------------------------------------------
51
+
52
+ private async getDiscovery(): Promise<OidcDiscovery> {
53
+ if (this.discoveryCache) return this.discoveryCache;
54
+
55
+ const baseUrl = this.config.serverUrl.replace(/\/+$/, "");
56
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
57
+ headers: { Accept: "application/json" },
58
+ });
59
+ if (!res.ok) {
60
+ throw new Error(`OIDC discovery failed: ${res.status}`);
61
+ }
62
+ this.discoveryCache = (await res.json()) as OidcDiscovery;
63
+ return this.discoveryCache;
64
+ }
65
+
66
+ // -----------------------------------------------------------------------
67
+ // Login redirect (PKCE)
68
+ // -----------------------------------------------------------------------
69
+
70
+ /**
71
+ * Start the OAuth2 PKCE login flow by redirecting to the IAM authorize endpoint.
72
+ *
73
+ * Generates PKCE challenge and state, stores them in session storage,
74
+ * then redirects the browser.
75
+ */
76
+ async signinRedirect(params?: { additionalParams?: Record<string, string> }): Promise<void> {
77
+ const discovery = await this.getDiscovery();
78
+ const { codeVerifier, codeChallenge } = await generatePkceChallenge();
79
+ const state = generateState();
80
+
81
+ this.storage.setItem(KEY_STATE, state);
82
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
83
+
84
+ const url = new URL(discovery.authorization_endpoint);
85
+ url.searchParams.set("client_id", this.config.clientId);
86
+ url.searchParams.set("response_type", "code");
87
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
88
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
89
+ url.searchParams.set("state", state);
90
+ url.searchParams.set("code_challenge", codeChallenge);
91
+ url.searchParams.set("code_challenge_method", "S256");
92
+
93
+ if (params?.additionalParams) {
94
+ for (const [k, v] of Object.entries(params.additionalParams)) {
95
+ url.searchParams.set(k, v);
96
+ }
97
+ }
98
+
99
+ window.location.href = url.toString();
100
+ }
101
+
102
+ // -----------------------------------------------------------------------
103
+ // Callback handling
104
+ // -----------------------------------------------------------------------
105
+
106
+ /**
107
+ * Handle the OAuth2 callback after redirect. Exchanges the authorization code
108
+ * for tokens using PKCE.
109
+ *
110
+ * Call this on your callback page (e.g. /auth/callback).
111
+ * Returns the token response, or throws if the state doesn't match.
112
+ */
113
+ async handleCallback(callbackUrl?: string): Promise<TokenResponse> {
114
+ const url = new URL(callbackUrl ?? window.location.href);
115
+ const code = url.searchParams.get("code");
116
+ const state = url.searchParams.get("state");
117
+ const error = url.searchParams.get("error");
118
+
119
+ if (error) {
120
+ const desc = url.searchParams.get("error_description") ?? error;
121
+ throw new Error(`OAuth error: ${desc}`);
122
+ }
123
+
124
+ if (!code) {
125
+ throw new Error("Missing authorization code in callback URL");
126
+ }
127
+
128
+ const savedState = this.storage.getItem(KEY_STATE);
129
+ if (!savedState || savedState !== state) {
130
+ throw new Error("OAuth state mismatch — possible CSRF attack");
131
+ }
132
+
133
+ const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
134
+ if (!codeVerifier) {
135
+ throw new Error("Missing PKCE code verifier — was signinRedirect() called?");
136
+ }
137
+
138
+ // Clean up one-time state
139
+ this.storage.removeItem(KEY_STATE);
140
+ this.storage.removeItem(KEY_CODE_VERIFIER);
141
+
142
+ const discovery = await this.getDiscovery();
143
+ const body = new URLSearchParams({
144
+ grant_type: "authorization_code",
145
+ client_id: this.config.clientId,
146
+ code,
147
+ redirect_uri: this.config.redirectUri,
148
+ code_verifier: codeVerifier,
149
+ });
150
+
151
+ const res = await fetch(discovery.token_endpoint, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
+ body: body.toString(),
155
+ });
156
+
157
+ if (!res.ok) {
158
+ const text = await res.text().catch(() => "");
159
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
160
+ }
161
+
162
+ const tokens = (await res.json()) as TokenResponse;
163
+ this.storeTokens(tokens);
164
+ return tokens;
165
+ }
166
+
167
+ // -----------------------------------------------------------------------
168
+ // Token refresh
169
+ // -----------------------------------------------------------------------
170
+
171
+ /** Refresh the access token using the stored refresh token. */
172
+ async refreshAccessToken(): Promise<TokenResponse> {
173
+ const refreshToken = this.storage.getItem(KEY_REFRESH_TOKEN);
174
+ if (!refreshToken) {
175
+ throw new Error("No refresh token available");
176
+ }
177
+
178
+ const discovery = await this.getDiscovery();
179
+ const body = new URLSearchParams({
180
+ grant_type: "refresh_token",
181
+ client_id: this.config.clientId,
182
+ refresh_token: refreshToken,
183
+ });
184
+
185
+ const res = await fetch(discovery.token_endpoint, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
188
+ body: body.toString(),
189
+ });
190
+
191
+ if (!res.ok) {
192
+ const text = await res.text().catch(() => "");
193
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
194
+ }
195
+
196
+ const tokens = (await res.json()) as TokenResponse;
197
+ this.storeTokens(tokens);
198
+ return tokens;
199
+ }
200
+
201
+ // -----------------------------------------------------------------------
202
+ // Popup signin
203
+ // -----------------------------------------------------------------------
204
+
205
+ /**
206
+ * Open the IAM login page in a popup window. Resolves when the popup
207
+ * completes the OAuth flow and returns tokens.
208
+ */
209
+ async signinPopup(params?: {
210
+ width?: number;
211
+ height?: number;
212
+ additionalParams?: Record<string, string>;
213
+ }): Promise<TokenResponse> {
214
+ const discovery = await this.getDiscovery();
215
+ const { codeVerifier, codeChallenge } = await generatePkceChallenge();
216
+ const state = generateState();
217
+
218
+ this.storage.setItem(KEY_STATE, state);
219
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
220
+
221
+ const url = new URL(discovery.authorization_endpoint);
222
+ url.searchParams.set("client_id", this.config.clientId);
223
+ url.searchParams.set("response_type", "code");
224
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
225
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
226
+ url.searchParams.set("state", state);
227
+ url.searchParams.set("code_challenge", codeChallenge);
228
+ url.searchParams.set("code_challenge_method", "S256");
229
+
230
+ if (params?.additionalParams) {
231
+ for (const [k, v] of Object.entries(params.additionalParams)) {
232
+ url.searchParams.set(k, v);
233
+ }
234
+ }
235
+
236
+ const width = params?.width ?? 600;
237
+ const height = params?.height ?? 700;
238
+ const left = window.screenX + (window.outerWidth - width) / 2;
239
+ const top = window.screenY + (window.outerHeight - height) / 2;
240
+
241
+ return new Promise<TokenResponse>((resolve, reject) => {
242
+ const popup = window.open(
243
+ url.toString(),
244
+ "hanzo_iam_login",
245
+ `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no`,
246
+ );
247
+
248
+ if (!popup) {
249
+ reject(new Error("Failed to open login popup — blocked by browser?"));
250
+ return;
251
+ }
252
+
253
+ const interval = setInterval(() => {
254
+ try {
255
+ if (popup.closed) {
256
+ clearInterval(interval);
257
+ reject(new Error("Login popup was closed before completing"));
258
+ return;
259
+ }
260
+ // Check if popup navigated to our redirect URI
261
+ const popupUrl = popup.location.href;
262
+ if (popupUrl.startsWith(this.config.redirectUri)) {
263
+ clearInterval(interval);
264
+ popup.close();
265
+ this.handleCallback(popupUrl).then(resolve, reject);
266
+ }
267
+ } catch {
268
+ // Cross-origin — popup is still on IAM domain, keep waiting
269
+ }
270
+ }, 200);
271
+ });
272
+ }
273
+
274
+ // -----------------------------------------------------------------------
275
+ // Silent signin (iframe)
276
+ // -----------------------------------------------------------------------
277
+
278
+ /**
279
+ * Attempt silent authentication via a hidden iframe.
280
+ * Useful for checking if the user has an active IAM session.
281
+ * Returns null if silent auth fails (user needs to log in interactively).
282
+ */
283
+ async signinSilent(timeoutMs = 5000): Promise<TokenResponse | null> {
284
+ const discovery = await this.getDiscovery();
285
+ const { codeVerifier, codeChallenge } = await generatePkceChallenge();
286
+ const state = generateState();
287
+
288
+ this.storage.setItem(KEY_STATE, state);
289
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
290
+
291
+ const url = new URL(discovery.authorization_endpoint);
292
+ url.searchParams.set("client_id", this.config.clientId);
293
+ url.searchParams.set("response_type", "code");
294
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
295
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
296
+ url.searchParams.set("state", state);
297
+ url.searchParams.set("code_challenge", codeChallenge);
298
+ url.searchParams.set("code_challenge_method", "S256");
299
+ url.searchParams.set("prompt", "none"); // No interactive login
300
+
301
+ return new Promise<TokenResponse | null>((resolve) => {
302
+ const iframe = document.createElement("iframe");
303
+ iframe.style.display = "none";
304
+
305
+ const timeout = setTimeout(() => {
306
+ cleanup();
307
+ resolve(null);
308
+ }, timeoutMs);
309
+
310
+ const cleanup = () => {
311
+ clearTimeout(timeout);
312
+ iframe.remove();
313
+ this.storage.removeItem(KEY_STATE);
314
+ this.storage.removeItem(KEY_CODE_VERIFIER);
315
+ };
316
+
317
+ iframe.addEventListener("load", () => {
318
+ try {
319
+ const iframeUrl = iframe.contentWindow?.location.href;
320
+ if (iframeUrl && iframeUrl.startsWith(this.config.redirectUri)) {
321
+ cleanup();
322
+ this.handleCallback(iframeUrl).then(
323
+ (tokens) => resolve(tokens),
324
+ () => resolve(null),
325
+ );
326
+ }
327
+ } catch {
328
+ // Cross-origin or error — silent auth failed
329
+ cleanup();
330
+ resolve(null);
331
+ }
332
+ });
333
+
334
+ iframe.src = url.toString();
335
+ document.body.appendChild(iframe);
336
+ });
337
+ }
338
+
339
+ // -----------------------------------------------------------------------
340
+ // Token management
341
+ // -----------------------------------------------------------------------
342
+
343
+ private storeTokens(tokens: TokenResponse): void {
344
+ this.storage.setItem(KEY_ACCESS_TOKEN, tokens.access_token);
345
+ if (tokens.refresh_token) {
346
+ this.storage.setItem(KEY_REFRESH_TOKEN, tokens.refresh_token);
347
+ }
348
+ if (tokens.id_token) {
349
+ this.storage.setItem(KEY_ID_TOKEN, tokens.id_token);
350
+ }
351
+ if (tokens.expires_in) {
352
+ const expiresAt = Date.now() + tokens.expires_in * 1000;
353
+ this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
354
+ }
355
+ }
356
+
357
+ /** Get the stored access token (may be expired). */
358
+ getAccessToken(): string | null {
359
+ return this.storage.getItem(KEY_ACCESS_TOKEN);
360
+ }
361
+
362
+ /** Get the stored refresh token. */
363
+ getRefreshToken(): string | null {
364
+ return this.storage.getItem(KEY_REFRESH_TOKEN);
365
+ }
366
+
367
+ /** Get the stored ID token. */
368
+ getIdToken(): string | null {
369
+ return this.storage.getItem(KEY_ID_TOKEN);
370
+ }
371
+
372
+ /** Check if the stored access token is expired. */
373
+ isTokenExpired(): boolean {
374
+ const expiresAt = this.storage.getItem(KEY_EXPIRES_AT);
375
+ if (!expiresAt) return true;
376
+ return Date.now() >= Number(expiresAt);
377
+ }
378
+
379
+ /**
380
+ * Get a valid access token — refreshes automatically if expired.
381
+ * Returns null if no token and no refresh token available.
382
+ */
383
+ async getValidAccessToken(): Promise<string | null> {
384
+ const token = this.getAccessToken();
385
+ if (token && !this.isTokenExpired()) {
386
+ return token;
387
+ }
388
+ if (this.getRefreshToken()) {
389
+ try {
390
+ const tokens = await this.refreshAccessToken();
391
+ return tokens.access_token;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /** Clear all stored tokens (logout). */
400
+ clearTokens(): void {
401
+ this.storage.removeItem(KEY_ACCESS_TOKEN);
402
+ this.storage.removeItem(KEY_REFRESH_TOKEN);
403
+ this.storage.removeItem(KEY_ID_TOKEN);
404
+ this.storage.removeItem(KEY_EXPIRES_AT);
405
+ this.storage.removeItem(KEY_STATE);
406
+ this.storage.removeItem(KEY_CODE_VERIFIER);
407
+ }
408
+
409
+ // -----------------------------------------------------------------------
410
+ // User info
411
+ // -----------------------------------------------------------------------
412
+
413
+ /** Fetch user info from the OIDC userinfo endpoint using the stored access token. */
414
+ async getUserInfo(): Promise<Record<string, unknown>> {
415
+ const token = await this.getValidAccessToken();
416
+ if (!token) {
417
+ throw new Error("No valid access token — user must log in");
418
+ }
419
+ const discovery = await this.getDiscovery();
420
+ const res = await fetch(discovery.userinfo_endpoint, {
421
+ headers: { Authorization: `Bearer ${token}` },
422
+ });
423
+ if (!res.ok) {
424
+ throw new Error(`Userinfo fetch failed (${res.status})`);
425
+ }
426
+ return (await res.json()) as Record<string, unknown>;
427
+ }
428
+
429
+ // -----------------------------------------------------------------------
430
+ // URL helpers
431
+ // -----------------------------------------------------------------------
432
+
433
+ /** Build the signup URL for the IAM server. */
434
+ getSignupUrl(params?: { enablePassword?: boolean }): string {
435
+ const base = this.config.serverUrl.replace(/\/+$/, "");
436
+ const app = this.config.appName ?? "app";
437
+ const org = this.config.orgName ?? "built-in";
438
+ let url = `${base}/signup/${app}`;
439
+ if (params?.enablePassword) {
440
+ url += "?enablePassword=true";
441
+ }
442
+ return url;
443
+ }
444
+
445
+ /** Build the user profile URL on the IAM server. */
446
+ getUserProfileUrl(username: string): string {
447
+ const base = this.config.serverUrl.replace(/\/+$/, "");
448
+ const org = this.config.orgName ?? "built-in";
449
+ return `${base}/users/${org}/${username}`;
450
+ }
451
+ }