@hanzo/iam 0.9.0 → 0.9.1

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.
Files changed (71) hide show
  1. package/dist/auth.cjs +111 -0
  2. package/dist/auth.cjs.map +1 -0
  3. package/dist/auth.d.cts +19 -0
  4. package/dist/auth.d.ts +7 -4
  5. package/dist/auth.js +94 -121
  6. package/dist/auth.js.map +1 -1
  7. package/dist/betterauth.cjs +34 -0
  8. package/dist/betterauth.cjs.map +1 -0
  9. package/dist/betterauth.d.cts +64 -0
  10. package/dist/betterauth.d.ts +7 -10
  11. package/dist/betterauth.js +28 -62
  12. package/dist/betterauth.js.map +1 -1
  13. package/dist/billing.cjs +8 -0
  14. package/dist/billing.cjs.map +1 -0
  15. package/dist/billing.d.cts +2 -0
  16. package/dist/billing.d.ts +2 -16
  17. package/dist/billing.js +5 -17
  18. package/dist/billing.js.map +1 -1
  19. package/dist/browser.cjs +680 -0
  20. package/dist/browser.cjs.map +1 -0
  21. package/dist/browser.d.cts +217 -0
  22. package/dist/browser.d.ts +10 -7
  23. package/dist/browser.js +645 -663
  24. package/dist/browser.js.map +1 -1
  25. package/dist/index.cjs +1087 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/{client.d.ts → index.d.cts} +23 -4
  28. package/dist/index.d.ts +86 -23
  29. package/dist/index.js +1077 -29
  30. package/dist/index.js.map +1 -1
  31. package/dist/nextauth.cjs +35 -0
  32. package/dist/nextauth.cjs.map +1 -0
  33. package/dist/nextauth.d.cts +55 -0
  34. package/dist/nextauth.d.ts +4 -7
  35. package/dist/nextauth.js +30 -66
  36. package/dist/nextauth.js.map +1 -1
  37. package/dist/passport.cjs +49 -0
  38. package/dist/passport.cjs.map +1 -0
  39. package/dist/passport.d.cts +47 -0
  40. package/dist/passport.d.ts +8 -5
  41. package/dist/passport.js +45 -65
  42. package/dist/passport.js.map +1 -1
  43. package/dist/react.cjs +1434 -0
  44. package/dist/react.cjs.map +1 -0
  45. package/dist/react.d.cts +133 -0
  46. package/dist/react.d.ts +18 -50
  47. package/dist/react.js +1399 -494
  48. package/dist/react.js.map +1 -1
  49. package/dist/types.cjs +4 -0
  50. package/dist/types.cjs.map +1 -0
  51. package/dist/types.d.cts +219 -0
  52. package/dist/types.d.ts +25 -24
  53. package/dist/types.js +2 -5
  54. package/dist/types.js.map +1 -1
  55. package/package.json +24 -13
  56. package/dist/auth.d.ts.map +0 -1
  57. package/dist/betterauth.d.ts.map +0 -1
  58. package/dist/billing.d.ts.map +0 -1
  59. package/dist/browser.d.ts.map +0 -1
  60. package/dist/client.d.ts.map +0 -1
  61. package/dist/client.js +0 -292
  62. package/dist/client.js.map +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/nextauth.d.ts.map +0 -1
  65. package/dist/passport.d.ts.map +0 -1
  66. package/dist/pkce.d.ts +0 -13
  67. package/dist/pkce.d.ts.map +0 -1
  68. package/dist/pkce.js +0 -36
  69. package/dist/pkce.js.map +0 -1
  70. package/dist/react.d.ts.map +0 -1
  71. package/dist/types.d.ts.map +0 -1
package/dist/browser.js CHANGED
@@ -1,695 +1,677 @@
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 for Hanzo IAM.
8
- */
9
- import { generatePKCEChallenge, generateState } from "./pkce.js";
10
- // ---------------------------------------------------------------------------
11
- // Storage keys
12
- // ---------------------------------------------------------------------------
13
- const STORAGE_PREFIX = "hanzo_iam_";
14
- const KEY_STATE = `${STORAGE_PREFIX}state`;
15
- const KEY_CODE_VERIFIER = `${STORAGE_PREFIX}code_verifier`;
16
- const KEY_ACCESS_TOKEN = `${STORAGE_PREFIX}access_token`;
17
- const KEY_REFRESH_TOKEN = `${STORAGE_PREFIX}refresh_token`;
18
- const KEY_ID_TOKEN = `${STORAGE_PREFIX}id_token`;
19
- const KEY_EXPIRES_AT = `${STORAGE_PREFIX}expires_at`;
20
- export class IAM {
21
- config;
22
- storage;
23
- discoveryCache = null;
24
- constructor(config) {
25
- this.config = config;
26
- this.storage = config.storage ?? sessionStorage;
27
- }
28
- // -----------------------------------------------------------------------
29
- // OIDC Discovery
30
- // -----------------------------------------------------------------------
31
- async getDiscovery() {
32
- if (this.discoveryCache)
33
- return this.discoveryCache;
34
- const baseUrl = this.config.serverUrl.replace(/\/+$/, "");
35
- // Try fetching the OIDC discovery document. If it fails (e.g. due to
36
- // CORS when the IAM server doesn't send Access-Control-Allow-Origin),
37
- // construct a fallback from well-known Hanzo IAM endpoint paths.
38
- try {
39
- const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
40
- headers: { Accept: "application/json" },
41
- });
42
- if (res.ok) {
43
- this.discoveryCache = (await res.json());
44
- return this.discoveryCache;
45
- }
46
- }
47
- catch {
48
- // CORS or network error — fall through to constructed discovery
49
- }
50
- this.discoveryCache = {
51
- issuer: baseUrl,
52
- authorization_endpoint: `${baseUrl}/oauth/authorize`,
53
- token_endpoint: `${baseUrl}/oauth/token`,
54
- userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
55
- jwks_uri: `${baseUrl}/.well-known/jwks`,
56
- response_types_supported: ["code", "token", "id_token"],
57
- grant_types_supported: ["authorization_code", "implicit", "refresh_token"],
58
- scopes_supported: ["openid", "email", "profile"],
59
- };
1
+ // src/pkce.ts
2
+ function generateRandomString(length) {
3
+ const array = new Uint8Array(length);
4
+ crypto.getRandomValues(array);
5
+ return Array.from(array, (b) => b.toString(36).padStart(2, "0")).join("").slice(0, length);
6
+ }
7
+ async function sha256(plain) {
8
+ const encoder = new TextEncoder();
9
+ return crypto.subtle.digest("SHA-256", encoder.encode(plain));
10
+ }
11
+ function base64UrlEncode(buffer) {
12
+ const bytes = new Uint8Array(buffer);
13
+ let binary = "";
14
+ for (const byte of bytes) {
15
+ binary += String.fromCharCode(byte);
16
+ }
17
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }
19
+ async function generatePKCEChallenge() {
20
+ const codeVerifier = generateRandomString(64);
21
+ const hash = await sha256(codeVerifier);
22
+ const codeChallenge = base64UrlEncode(hash);
23
+ return { codeVerifier, codeChallenge };
24
+ }
25
+ function generateState() {
26
+ return generateRandomString(32);
27
+ }
28
+
29
+ // src/browser.ts
30
+ var STORAGE_PREFIX = "hanzo_iam_";
31
+ var KEY_STATE = `${STORAGE_PREFIX}state`;
32
+ var KEY_CODE_VERIFIER = `${STORAGE_PREFIX}code_verifier`;
33
+ var KEY_ACCESS_TOKEN = `${STORAGE_PREFIX}access_token`;
34
+ var KEY_REFRESH_TOKEN = `${STORAGE_PREFIX}refresh_token`;
35
+ var KEY_ID_TOKEN = `${STORAGE_PREFIX}id_token`;
36
+ var KEY_EXPIRES_AT = `${STORAGE_PREFIX}expires_at`;
37
+ var IAM = class {
38
+ config;
39
+ storage;
40
+ discoveryCache = null;
41
+ constructor(config) {
42
+ this.config = config;
43
+ this.storage = config.storage ?? sessionStorage;
44
+ }
45
+ // -----------------------------------------------------------------------
46
+ // OIDC Discovery
47
+ // -----------------------------------------------------------------------
48
+ async getDiscovery() {
49
+ if (this.discoveryCache) return this.discoveryCache;
50
+ const baseUrl = this.config.serverUrl.replace(/\/+$/, "");
51
+ try {
52
+ const res = await fetch(`${baseUrl}/.well-known/openid-configuration`, {
53
+ headers: { Accept: "application/json" }
54
+ });
55
+ if (res.ok) {
56
+ this.discoveryCache = await res.json();
60
57
  return this.discoveryCache;
58
+ }
59
+ } catch {
61
60
  }
62
- // -----------------------------------------------------------------------
63
- // Login redirect (PKCE)
64
- // -----------------------------------------------------------------------
65
- /**
66
- * Start the OAuth2 PKCE login flow by redirecting to the IAM authorize endpoint.
67
- *
68
- * Generates PKCE challenge and state, stores them in session storage,
69
- * then redirects the browser.
70
- */
71
- async signinRedirect(params) {
72
- const discovery = await this.getDiscovery();
73
- const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
74
- const state = generateState();
75
- this.storage.setItem(KEY_STATE, state);
76
- this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
77
- const url = new URL(discovery.authorization_endpoint);
78
- url.searchParams.set("client_id", this.config.clientId);
79
- url.searchParams.set("response_type", "code");
80
- url.searchParams.set("redirect_uri", this.config.redirectUri);
81
- url.searchParams.set("scope", this.config.scope ?? "openid profile email");
82
- url.searchParams.set("state", state);
83
- url.searchParams.set("code_challenge", codeChallenge);
84
- url.searchParams.set("code_challenge_method", "S256");
85
- if (params?.additionalParams) {
86
- for (const [k, v] of Object.entries(params.additionalParams)) {
87
- url.searchParams.set(k, v);
88
- }
89
- }
90
- window.location.href = url.toString();
61
+ this.discoveryCache = {
62
+ issuer: baseUrl,
63
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
64
+ token_endpoint: `${baseUrl}/oauth/token`,
65
+ userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
66
+ jwks_uri: `${baseUrl}/.well-known/jwks`,
67
+ response_types_supported: ["code", "token", "id_token"],
68
+ grant_types_supported: ["authorization_code", "implicit", "refresh_token"],
69
+ scopes_supported: ["openid", "email", "profile"]
70
+ };
71
+ return this.discoveryCache;
72
+ }
73
+ // -----------------------------------------------------------------------
74
+ // Login redirect (PKCE)
75
+ // -----------------------------------------------------------------------
76
+ /**
77
+ * Start the OAuth2 PKCE login flow by redirecting to the IAM authorize endpoint.
78
+ *
79
+ * Generates PKCE challenge and state, stores them in session storage,
80
+ * then redirects the browser.
81
+ */
82
+ async signinRedirect(params) {
83
+ const discovery = await this.getDiscovery();
84
+ const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
85
+ const state = generateState();
86
+ this.storage.setItem(KEY_STATE, state);
87
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
88
+ const url = new URL(discovery.authorization_endpoint);
89
+ url.searchParams.set("client_id", this.config.clientId);
90
+ url.searchParams.set("response_type", "code");
91
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
92
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
93
+ url.searchParams.set("state", state);
94
+ url.searchParams.set("code_challenge", codeChallenge);
95
+ url.searchParams.set("code_challenge_method", "S256");
96
+ if (params?.additionalParams) {
97
+ for (const [k, v] of Object.entries(params.additionalParams)) {
98
+ url.searchParams.set(k, v);
99
+ }
91
100
  }
92
- // -----------------------------------------------------------------------
93
- // Callback handling
94
- // -----------------------------------------------------------------------
95
- /**
96
- * Handle the OAuth2 callback after redirect. Exchanges the authorization code
97
- * for tokens using PKCE.
98
- *
99
- * Call this on your callback page (e.g. /auth/callback).
100
- * Returns the token response, or throws if the state doesn't match.
101
- */
102
- async handleCallback(callbackUrl) {
103
- const url = new URL(callbackUrl ?? window.location.href);
104
- const error = url.searchParams.get("error");
105
- if (error) {
106
- const desc = url.searchParams.get("error_description") ?? error;
107
- throw new Error(`OAuth error: ${desc}`);
108
- }
109
- const state = url.searchParams.get("state");
110
- const savedState = this.storage.getItem(KEY_STATE);
111
- if (savedState && state !== savedState) {
112
- throw new Error("OAuth state mismatch — possible CSRF attack");
113
- }
114
- // Implicit flow: access_token returned directly in URL
115
- const accessToken = url.searchParams.get("access_token");
116
- if (accessToken) {
117
- this.storage.removeItem(KEY_STATE);
118
- this.storage.removeItem(KEY_CODE_VERIFIER);
119
- const tokens = {
120
- access_token: accessToken,
121
- token_type: "Bearer",
122
- refresh_token: url.searchParams.get("refresh_token") ?? undefined,
123
- expires_in: 7200,
124
- };
125
- this.storeTokens(tokens);
126
- return toIAMToken(tokens);
127
- }
128
- // Authorization code flow: exchange code for tokens via PKCE
129
- const code = url.searchParams.get("code");
130
- if (!code) {
131
- throw new Error("Missing authorization code in callback URL");
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
- // Clean up one-time state
138
- this.storage.removeItem(KEY_STATE);
139
- this.storage.removeItem(KEY_CODE_VERIFIER);
140
- const discovery = await this.getDiscovery();
141
- const body = new URLSearchParams({
142
- grant_type: "authorization_code",
143
- client_id: this.config.clientId,
144
- code,
145
- redirect_uri: this.config.redirectUri,
146
- code_verifier: codeVerifier,
147
- });
148
- // Use proxy URL when configured to avoid CORS on the token endpoint.
149
- const tokenUrl = this.config.proxyBaseUrl
150
- ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token`
151
- : discovery.token_endpoint;
152
- const res = await fetch(tokenUrl, {
153
- method: "POST",
154
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
155
- body: body.toString(),
156
- });
157
- if (!res.ok) {
158
- const text = await res.text().catch(() => "");
159
- throw new Error(`Token exchange failed (${res.status}): ${text}`);
160
- }
161
- const tokens = (await res.json());
162
- this.storeTokens(tokens);
163
- return toIAMToken(tokens);
101
+ window.location.href = url.toString();
102
+ }
103
+ // -----------------------------------------------------------------------
104
+ // Callback handling
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) {
114
+ const url = new URL(callbackUrl ?? window.location.href);
115
+ const error = url.searchParams.get("error");
116
+ if (error) {
117
+ const desc = url.searchParams.get("error_description") ?? error;
118
+ throw new Error(`OAuth error: ${desc}`);
164
119
  }
165
- // -----------------------------------------------------------------------
166
- // Token refresh
167
- // -----------------------------------------------------------------------
168
- /** Refresh the access token using the stored refresh token. */
169
- async refreshAccessToken() {
170
- const refreshToken = this.storage.getItem(KEY_REFRESH_TOKEN);
171
- if (!refreshToken) {
172
- throw new Error("No refresh token available");
173
- }
174
- const discovery = await this.getDiscovery();
175
- const body = new URLSearchParams({
176
- grant_type: "refresh_token",
177
- client_id: this.config.clientId,
178
- refresh_token: refreshToken,
179
- });
180
- const tokenUrl = this.config.proxyBaseUrl
181
- ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token`
182
- : discovery.token_endpoint;
183
- const res = await fetch(tokenUrl, {
184
- method: "POST",
185
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
186
- body: body.toString(),
187
- });
188
- if (!res.ok) {
189
- const text = await res.text().catch(() => "");
190
- throw new Error(`Token refresh failed (${res.status}): ${text}`);
191
- }
192
- const tokens = (await res.json());
193
- this.storeTokens(tokens);
194
- return toIAMToken(tokens);
120
+ const state = url.searchParams.get("state");
121
+ const savedState = this.storage.getItem(KEY_STATE);
122
+ if (savedState && state !== savedState) {
123
+ throw new Error("OAuth state mismatch \u2014 possible CSRF attack");
195
124
  }
196
- // -----------------------------------------------------------------------
197
- // Popup signin
198
- // -----------------------------------------------------------------------
199
- /**
200
- * Open the IAM login page in a popup window. Resolves when the popup
201
- * completes the OAuth flow and returns tokens.
202
- */
203
- async signinPopup(params) {
204
- const discovery = await this.getDiscovery();
205
- const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
206
- const state = generateState();
207
- this.storage.setItem(KEY_STATE, state);
208
- this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
209
- const url = new URL(discovery.authorization_endpoint);
210
- url.searchParams.set("client_id", this.config.clientId);
211
- url.searchParams.set("response_type", "code");
212
- url.searchParams.set("redirect_uri", this.config.redirectUri);
213
- url.searchParams.set("scope", this.config.scope ?? "openid profile email");
214
- url.searchParams.set("state", state);
215
- url.searchParams.set("code_challenge", codeChallenge);
216
- url.searchParams.set("code_challenge_method", "S256");
217
- if (params?.additionalParams) {
218
- for (const [k, v] of Object.entries(params.additionalParams)) {
219
- url.searchParams.set(k, v);
220
- }
221
- }
222
- const width = params?.width ?? 600;
223
- const height = params?.height ?? 700;
224
- const left = window.screenX + (window.outerWidth - width) / 2;
225
- const top = window.screenY + (window.outerHeight - height) / 2;
226
- return new Promise((resolve, reject) => {
227
- const popup = window.open(url.toString(), "hanzo_iam_login", `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no`);
228
- if (!popup) {
229
- reject(new Error("Failed to open login popup — blocked by browser?"));
230
- return;
231
- }
232
- const interval = setInterval(() => {
233
- try {
234
- if (popup.closed) {
235
- clearInterval(interval);
236
- reject(new Error("Login popup was closed before completing"));
237
- return;
238
- }
239
- // Check if popup navigated to our redirect URI
240
- const popupUrl = popup.location.href;
241
- if (popupUrl.startsWith(this.config.redirectUri)) {
242
- clearInterval(interval);
243
- popup.close();
244
- this.handleCallback(popupUrl).then(resolve, reject);
245
- }
246
- }
247
- catch {
248
- // Cross-origin — popup is still on IAM domain, keep waiting
249
- }
250
- }, 200);
251
- });
125
+ const accessToken = url.searchParams.get("access_token");
126
+ if (accessToken) {
127
+ this.storage.removeItem(KEY_STATE);
128
+ this.storage.removeItem(KEY_CODE_VERIFIER);
129
+ const tokens2 = {
130
+ access_token: accessToken,
131
+ token_type: "Bearer",
132
+ refresh_token: url.searchParams.get("refresh_token") ?? void 0,
133
+ expires_in: 7200
134
+ };
135
+ this.storeTokens(tokens2);
136
+ return toIAMToken(tokens2);
252
137
  }
253
- // -----------------------------------------------------------------------
254
- // Silent signin (iframe)
255
- // -----------------------------------------------------------------------
256
- /**
257
- * Attempt silent authentication via a hidden iframe.
258
- * Useful for checking if the user has an active IAM session.
259
- * Returns null if silent auth fails (user needs to log in interactively).
260
- */
261
- async signinSilent(timeoutMs = 5000) {
262
- const discovery = await this.getDiscovery();
263
- const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
264
- const state = generateState();
265
- this.storage.setItem(KEY_STATE, state);
266
- this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
267
- const url = new URL(discovery.authorization_endpoint);
268
- url.searchParams.set("client_id", this.config.clientId);
269
- url.searchParams.set("response_type", "code");
270
- url.searchParams.set("redirect_uri", this.config.redirectUri);
271
- url.searchParams.set("scope", this.config.scope ?? "openid profile email");
272
- url.searchParams.set("state", state);
273
- url.searchParams.set("code_challenge", codeChallenge);
274
- url.searchParams.set("code_challenge_method", "S256");
275
- url.searchParams.set("prompt", "none"); // No interactive login
276
- return new Promise((resolve) => {
277
- const iframe = document.createElement("iframe");
278
- iframe.style.display = "none";
279
- const timeout = setTimeout(() => {
280
- cleanup();
281
- resolve(null);
282
- }, timeoutMs);
283
- const cleanup = () => {
284
- clearTimeout(timeout);
285
- iframe.remove();
286
- this.storage.removeItem(KEY_STATE);
287
- this.storage.removeItem(KEY_CODE_VERIFIER);
288
- };
289
- iframe.addEventListener("load", () => {
290
- try {
291
- const iframeUrl = iframe.contentWindow?.location.href;
292
- if (iframeUrl && iframeUrl.startsWith(this.config.redirectUri)) {
293
- cleanup();
294
- this.handleCallback(iframeUrl).then((tokens) => resolve(tokens), () => resolve(null));
295
- }
296
- }
297
- catch {
298
- // Cross-origin or error — silent auth failed
299
- cleanup();
300
- resolve(null);
301
- }
302
- });
303
- iframe.src = url.toString();
304
- document.body.appendChild(iframe);
305
- });
138
+ const code = url.searchParams.get("code");
139
+ if (!code) {
140
+ throw new Error("Missing authorization code in callback URL");
306
141
  }
307
- // -----------------------------------------------------------------------
308
- // Token management
309
- // -----------------------------------------------------------------------
310
- storeTokens(tokens) {
311
- this.storage.setItem(KEY_ACCESS_TOKEN, tokens.access_token);
312
- if (tokens.refresh_token) {
313
- this.storage.setItem(KEY_REFRESH_TOKEN, tokens.refresh_token);
314
- }
315
- if (tokens.id_token) {
316
- this.storage.setItem(KEY_ID_TOKEN, tokens.id_token);
317
- }
318
- if (tokens.expires_in) {
319
- const expiresAt = Date.now() + tokens.expires_in * 1000;
320
- this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
321
- }
142
+ const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
143
+ if (!codeVerifier) {
144
+ throw new Error("Missing PKCE code verifier \u2014 was signinRedirect() called?");
322
145
  }
323
- /** Get the stored access token (may be expired). */
324
- getAccessToken() {
325
- return this.storage.getItem(KEY_ACCESS_TOKEN);
146
+ this.storage.removeItem(KEY_STATE);
147
+ this.storage.removeItem(KEY_CODE_VERIFIER);
148
+ const discovery = await this.getDiscovery();
149
+ const body = new URLSearchParams({
150
+ grant_type: "authorization_code",
151
+ client_id: this.config.clientId,
152
+ code,
153
+ redirect_uri: this.config.redirectUri,
154
+ code_verifier: codeVerifier
155
+ });
156
+ const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
157
+ const res = await fetch(tokenUrl, {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
160
+ body: body.toString()
161
+ });
162
+ if (!res.ok) {
163
+ const text = await res.text().catch(() => "");
164
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
326
165
  }
327
- /** Get the stored refresh token. */
328
- getRefreshToken() {
329
- return this.storage.getItem(KEY_REFRESH_TOKEN);
166
+ const tokens = await res.json();
167
+ this.storeTokens(tokens);
168
+ return toIAMToken(tokens);
169
+ }
170
+ // -----------------------------------------------------------------------
171
+ // Token refresh
172
+ // -----------------------------------------------------------------------
173
+ /** Refresh the access token using the stored refresh token. */
174
+ async refreshAccessToken() {
175
+ const refreshToken = this.storage.getItem(KEY_REFRESH_TOKEN);
176
+ if (!refreshToken) {
177
+ throw new Error("No refresh token available");
330
178
  }
331
- /** Get the stored ID token. */
332
- getIdToken() {
333
- return this.storage.getItem(KEY_ID_TOKEN);
179
+ const discovery = await this.getDiscovery();
180
+ const body = new URLSearchParams({
181
+ grant_type: "refresh_token",
182
+ client_id: this.config.clientId,
183
+ refresh_token: refreshToken
184
+ });
185
+ const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
186
+ const res = await fetch(tokenUrl, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
189
+ body: body.toString()
190
+ });
191
+ if (!res.ok) {
192
+ const text = await res.text().catch(() => "");
193
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
334
194
  }
335
- /** Check if the stored access token is expired. */
336
- isTokenExpired() {
337
- const expiresAt = this.storage.getItem(KEY_EXPIRES_AT);
338
- if (!expiresAt)
339
- return true;
340
- return Date.now() >= Number(expiresAt);
195
+ const tokens = await res.json();
196
+ this.storeTokens(tokens);
197
+ return toIAMToken(tokens);
198
+ }
199
+ // -----------------------------------------------------------------------
200
+ // Popup signin
201
+ // -----------------------------------------------------------------------
202
+ /**
203
+ * Open the IAM login page in a popup window. Resolves when the popup
204
+ * completes the OAuth flow and returns tokens.
205
+ */
206
+ async signinPopup(params) {
207
+ const discovery = await this.getDiscovery();
208
+ const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
209
+ const state = generateState();
210
+ this.storage.setItem(KEY_STATE, state);
211
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
212
+ const url = new URL(discovery.authorization_endpoint);
213
+ url.searchParams.set("client_id", this.config.clientId);
214
+ url.searchParams.set("response_type", "code");
215
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
216
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
217
+ url.searchParams.set("state", state);
218
+ url.searchParams.set("code_challenge", codeChallenge);
219
+ url.searchParams.set("code_challenge_method", "S256");
220
+ if (params?.additionalParams) {
221
+ for (const [k, v] of Object.entries(params.additionalParams)) {
222
+ url.searchParams.set(k, v);
223
+ }
341
224
  }
342
- /**
343
- * Get a valid access token — refreshes automatically if expired.
344
- * Returns null if no token and no refresh token available.
345
- */
346
- async getValidAccessToken() {
347
- const token = this.getAccessToken();
348
- if (token && !this.isTokenExpired()) {
349
- return token;
350
- }
351
- if (this.getRefreshToken()) {
352
- try {
353
- const tokens = await this.refreshAccessToken();
354
- return tokens.accessToken;
355
- }
356
- catch {
357
- return null;
358
- }
225
+ const width = params?.width ?? 600;
226
+ const height = params?.height ?? 700;
227
+ const left = window.screenX + (window.outerWidth - width) / 2;
228
+ const top = window.screenY + (window.outerHeight - height) / 2;
229
+ return new Promise((resolve, reject) => {
230
+ const popup = window.open(
231
+ url.toString(),
232
+ "hanzo_iam_login",
233
+ `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no`
234
+ );
235
+ if (!popup) {
236
+ reject(new Error("Failed to open login popup \u2014 blocked by browser?"));
237
+ return;
238
+ }
239
+ const interval = setInterval(() => {
240
+ try {
241
+ if (popup.closed) {
242
+ clearInterval(interval);
243
+ reject(new Error("Login popup was closed before completing"));
244
+ return;
245
+ }
246
+ const popupUrl = popup.location.href;
247
+ if (popupUrl.startsWith(this.config.redirectUri)) {
248
+ clearInterval(interval);
249
+ popup.close();
250
+ this.handleCallback(popupUrl).then(resolve, reject);
251
+ }
252
+ } catch {
359
253
  }
360
- return null;
361
- }
362
- /** Clear all stored tokens (logout). */
363
- clearTokens() {
364
- this.storage.removeItem(KEY_ACCESS_TOKEN);
365
- this.storage.removeItem(KEY_REFRESH_TOKEN);
366
- this.storage.removeItem(KEY_ID_TOKEN);
367
- this.storage.removeItem(KEY_EXPIRES_AT);
254
+ }, 200);
255
+ });
256
+ }
257
+ // -----------------------------------------------------------------------
258
+ // Silent signin (iframe)
259
+ // -----------------------------------------------------------------------
260
+ /**
261
+ * Attempt silent authentication via a hidden iframe.
262
+ * Useful for checking if the user has an active IAM session.
263
+ * Returns null if silent auth fails (user needs to log in interactively).
264
+ */
265
+ async signinSilent(timeoutMs = 5e3) {
266
+ const discovery = await this.getDiscovery();
267
+ const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
268
+ const state = generateState();
269
+ this.storage.setItem(KEY_STATE, state);
270
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
271
+ const url = new URL(discovery.authorization_endpoint);
272
+ url.searchParams.set("client_id", this.config.clientId);
273
+ url.searchParams.set("response_type", "code");
274
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
275
+ url.searchParams.set("scope", this.config.scope ?? "openid profile email");
276
+ url.searchParams.set("state", state);
277
+ url.searchParams.set("code_challenge", codeChallenge);
278
+ url.searchParams.set("code_challenge_method", "S256");
279
+ url.searchParams.set("prompt", "none");
280
+ return new Promise((resolve) => {
281
+ const iframe = document.createElement("iframe");
282
+ iframe.style.display = "none";
283
+ const timeout = setTimeout(() => {
284
+ cleanup();
285
+ resolve(null);
286
+ }, timeoutMs);
287
+ const cleanup = () => {
288
+ clearTimeout(timeout);
289
+ iframe.remove();
368
290
  this.storage.removeItem(KEY_STATE);
369
291
  this.storage.removeItem(KEY_CODE_VERIFIER);
370
- }
371
- // -----------------------------------------------------------------------
372
- // User info
373
- // -----------------------------------------------------------------------
374
- /** Fetch user info from the OIDC userinfo endpoint using the stored access token. */
375
- async getUserInfo() {
376
- const token = await this.getValidAccessToken();
377
- if (!token) {
378
- throw new Error("No valid access token — user must log in");
379
- }
380
- const discovery = await this.getDiscovery();
381
- const userinfoUrl = this.config.proxyBaseUrl
382
- ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/userinfo`
383
- : discovery.userinfo_endpoint;
384
- const res = await fetch(userinfoUrl, {
385
- headers: { Authorization: `Bearer ${token}` },
386
- });
387
- if (!res.ok) {
388
- throw new Error(`Userinfo fetch failed (${res.status})`);
292
+ };
293
+ iframe.addEventListener("load", () => {
294
+ try {
295
+ const iframeUrl = iframe.contentWindow?.location.href;
296
+ if (iframeUrl && iframeUrl.startsWith(this.config.redirectUri)) {
297
+ cleanup();
298
+ this.handleCallback(iframeUrl).then(
299
+ (tokens) => resolve(tokens),
300
+ () => resolve(null)
301
+ );
302
+ }
303
+ } catch {
304
+ cleanup();
305
+ resolve(null);
389
306
  }
390
- return (await res.json());
307
+ });
308
+ iframe.src = url.toString();
309
+ document.body.appendChild(iframe);
310
+ });
311
+ }
312
+ // -----------------------------------------------------------------------
313
+ // Token management
314
+ // -----------------------------------------------------------------------
315
+ storeTokens(tokens) {
316
+ this.storage.setItem(KEY_ACCESS_TOKEN, tokens.access_token);
317
+ if (tokens.refresh_token) {
318
+ this.storage.setItem(KEY_REFRESH_TOKEN, tokens.refresh_token);
391
319
  }
392
- // -----------------------------------------------------------------------
393
- // URL helpers
394
- // -----------------------------------------------------------------------
395
- /** Build the signup URL for the IAM server. */
396
- getSignupUrl(params) {
397
- const base = this.config.serverUrl.replace(/\/+$/, "");
398
- const app = this.config.appName ?? "app";
399
- const org = this.config.orgName ?? "built-in";
400
- let url = `${base}/signup/${app}`;
401
- if (params?.enablePassword) {
402
- url += "?enablePassword=true";
403
- }
404
- return url;
320
+ if (tokens.id_token) {
321
+ this.storage.setItem(KEY_ID_TOKEN, tokens.id_token);
405
322
  }
406
- /** Build the user profile URL on the IAM server. */
407
- getUserProfileUrl(username) {
408
- const base = this.config.serverUrl.replace(/\/+$/, "");
409
- const org = this.config.orgName ?? "built-in";
410
- return `${base}/users/${org}/${username}`;
323
+ if (tokens.expires_in) {
324
+ const expiresAt = Date.now() + tokens.expires_in * 1e3;
325
+ this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
411
326
  }
412
- // -----------------------------------------------------------------------
413
- // Casdoor REST surface (signup, OTP, REST login, phone lookup)
414
- //
415
- // The OIDC layer covers redirect/PKCE, token exchange, refresh, userinfo.
416
- // These methods cover the Casdoor-native endpoints that don't have an OIDC
417
- // analogue: phone/email OTP, custom signup with verification codes, and
418
- // direct REST login that returns an authorization code in one round-trip
419
- // (used as the bridge between OTP collection and `exchangeCode`).
420
- //
421
- // All paths are gateway-canonical (`/login`, `/signup`,
422
- // `/send-verification-code`, `/get-phone-user`) — point `serverUrl` at the
423
- // gateway prefix (e.g. `https://api.dev.satschel.com/v1/iam`) and the
424
- // gateway proxies to Casdoor's `/api/*` internally.
425
- // -----------------------------------------------------------------------
426
- /**
427
- * Send a verification code to a phone or email destination.
428
- *
429
- * @param contact `{ phone, countryCode }` for SMS, `{ email }` for email.
430
- * @param method Casdoor method: `login`, `signup`, `forget`, `mfaSetup`, etc.
431
- */
432
- async sendVerificationCode(contact, method = "login") {
433
- // 'reset' is an idiomatic alias for Casdoor's 'forget'.
434
- if (method === "reset")
435
- method = "forget";
436
- const isPhone = "phone" in contact;
437
- const dest = isPhone ? contact.phone : contact.email;
438
- const params = {
439
- applicationId: `admin/${this.config.appName ?? "app"}`,
440
- dest,
441
- type: isPhone ? "phone" : "email",
442
- method,
443
- captchaType: "none",
444
- captchaToken: "",
445
- };
446
- if (isPhone)
447
- params.countryCode = contact.countryCode;
448
- const url = `${this.config.serverUrl.replace(/\/+$/, "")}/send-verification-code`;
449
- const res = await fetch(url, {
450
- method: "POST",
451
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
452
- body: new URLSearchParams(params).toString(),
453
- });
454
- const data = await res.json().catch(() => ({}));
455
- if (data.status === "ok")
456
- return { ok: true };
457
- const msg = data.msg || `send-verification-code failed (${res.status})`;
458
- // Provider misconfig is treated as soft success in dev — Casdoor still
459
- // generates the code and stores it for verification.
460
- if (msg.includes("SMS provider") || msg.includes("provider"))
461
- return { ok: true };
462
- return { ok: false, error: msg };
327
+ }
328
+ /** Get the stored access token (may be expired). */
329
+ getAccessToken() {
330
+ return this.storage.getItem(KEY_ACCESS_TOKEN);
331
+ }
332
+ /** Get the stored refresh token. */
333
+ getRefreshToken() {
334
+ return this.storage.getItem(KEY_REFRESH_TOKEN);
335
+ }
336
+ /** Get the stored ID token. */
337
+ getIdToken() {
338
+ return this.storage.getItem(KEY_ID_TOKEN);
339
+ }
340
+ /** Check if the stored access token is expired. */
341
+ isTokenExpired() {
342
+ const expiresAt = this.storage.getItem(KEY_EXPIRES_AT);
343
+ if (!expiresAt) return true;
344
+ return Date.now() >= Number(expiresAt);
345
+ }
346
+ /**
347
+ * Get a valid access token — refreshes automatically if expired.
348
+ * Returns null if no token and no refresh token available.
349
+ */
350
+ async getValidAccessToken() {
351
+ const token = this.getAccessToken();
352
+ if (token && !this.isTokenExpired()) {
353
+ return token;
463
354
  }
464
- /**
465
- * Look up whether a phone number is registered. Returns `{ exists: false }`
466
- * on 404 or unknown numbers; `{ exists: true }` when Casdoor confirms a user.
467
- */
468
- async lookupPhoneUser(phone, countryCode) {
469
- const url = `${this.config.serverUrl.replace(/\/+$/, "")}/get-phone-user`;
470
- const res = await fetch(url, {
471
- method: "POST",
472
- headers: { "Content-Type": "application/json" },
473
- body: JSON.stringify({
474
- application: this.config.appName ?? "app",
475
- phone,
476
- countryCode,
477
- }),
478
- });
479
- if (res.status === 404)
480
- return { exists: false };
481
- if (!res.ok)
482
- return { exists: false, error: `lookupPhoneUser failed (${res.status})` };
483
- return { exists: true };
355
+ if (this.getRefreshToken()) {
356
+ try {
357
+ const tokens = await this.refreshAccessToken();
358
+ return tokens.accessToken;
359
+ } catch {
360
+ return null;
361
+ }
484
362
  }
485
- /**
486
- * Casdoor REST signup. Returns the new user's id on success.
487
- *
488
- * Phone signup flow: send phoneCode via `sendVerificationCode`, then call
489
- * this with the OTP in `phoneCode`. Casdoor verifies the code internally.
490
- * Email signup flow: same with `email` + `emailCode`.
491
- */
492
- async signup(params) {
493
- const username = params.username ?? params.name;
494
- const password = params.password ?? `Liq${Date.now()}!`;
495
- const body = {
496
- application: this.config.appName ?? "app",
497
- organization: this.config.orgName ?? "built-in",
498
- name: params.name,
499
- username,
500
- password,
501
- confirm: password,
502
- agreement: true,
503
- };
504
- if (params.method === "email") {
505
- body.email = params.email;
506
- if (params.emailCode)
507
- body.emailCode = params.emailCode;
508
- }
509
- else {
510
- body.phone = params.phone;
511
- body.countryCode = params.countryCode;
512
- if (params.phoneCode)
513
- body.phoneCode = params.phoneCode;
514
- if (params.email)
515
- body.email = params.email;
516
- if (params.emailCode)
517
- body.emailCode = params.emailCode;
518
- }
519
- const url = `${this.config.serverUrl.replace(/\/+$/, "")}/signup`;
520
- const res = await fetch(url, {
521
- method: "POST",
522
- headers: { "Content-Type": "application/json" },
523
- body: JSON.stringify(body),
524
- });
525
- const data = await res.json().catch(() => ({}));
526
- if (data.status === "ok")
527
- return { ok: true, id: data.data2 || data.data };
528
- return { ok: false, error: data.msg || `signup failed (${res.status})` };
363
+ return null;
364
+ }
365
+ /** Clear all stored tokens (logout). */
366
+ clearTokens() {
367
+ this.storage.removeItem(KEY_ACCESS_TOKEN);
368
+ this.storage.removeItem(KEY_REFRESH_TOKEN);
369
+ this.storage.removeItem(KEY_ID_TOKEN);
370
+ this.storage.removeItem(KEY_EXPIRES_AT);
371
+ this.storage.removeItem(KEY_STATE);
372
+ this.storage.removeItem(KEY_CODE_VERIFIER);
373
+ }
374
+ // -----------------------------------------------------------------------
375
+ // User info
376
+ // -----------------------------------------------------------------------
377
+ /** Fetch user info from the OIDC userinfo endpoint using the stored access token. */
378
+ async getUserInfo() {
379
+ const token = await this.getValidAccessToken();
380
+ if (!token) {
381
+ throw new Error("No valid access token \u2014 user must log in");
529
382
  }
530
- /**
531
- * REST login that returns an authorization code (Casdoor `/login`).
532
- *
533
- * Use this when you want the caller to drive the PKCE flow without a
534
- * full redirect — collect credentials in your own UI, get a code back,
535
- * then call `exchangeCodeForToken` to land tokens.
536
- */
537
- async loginWithCredentials(params) {
538
- const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
539
- this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
540
- const url = new URL(`${this.config.serverUrl.replace(/\/+$/, "")}/login`);
541
- url.searchParams.set("code_challenge", codeChallenge);
542
- url.searchParams.set("code_challenge_method", "S256");
543
- const res = await fetch(url.toString(), {
544
- method: "POST",
545
- headers: { "Content-Type": "application/json" },
546
- body: JSON.stringify({
547
- application: this.config.appName ?? "app",
548
- organization: this.config.orgName ?? "built-in",
549
- username: params.username,
550
- password: params.password,
551
- type: params.type ?? "code",
552
- clientId: this.config.clientId,
553
- redirectUri: params.redirectUri ?? this.config.redirectUri,
554
- codeChallenge,
555
- codeChallengeMethod: "S256",
556
- autoSignin: true,
557
- }),
558
- });
559
- const data = await res.json().catch(() => ({}));
560
- if (data.status === "ok" && data.data)
561
- return { ok: true, code: data.data };
562
- return { ok: false, error: data.msg || `login failed (${res.status})` };
383
+ const discovery = await this.getDiscovery();
384
+ const userinfoUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/userinfo` : discovery.userinfo_endpoint;
385
+ const res = await fetch(userinfoUrl, {
386
+ headers: { Authorization: `Bearer ${token}` }
387
+ });
388
+ if (!res.ok) {
389
+ throw new Error(`Userinfo fetch failed (${res.status})`);
563
390
  }
564
- /**
565
- * Exchange an authorization code for tokens using the stored PKCE verifier.
566
- * Pairs with `loginWithCredentials` for a code → tokens round-trip.
567
- */
568
- async exchangeCodeForToken(code, redirectUri) {
569
- const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
570
- if (!codeVerifier) {
571
- throw new Error("Missing PKCE verifier — call loginWithCredentials() first");
572
- }
573
- this.storage.removeItem(KEY_CODE_VERIFIER);
574
- const discovery = await this.getDiscovery();
575
- const tokenUrl = this.config.proxyBaseUrl
576
- ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token`
577
- : discovery.token_endpoint;
578
- const body = new URLSearchParams({
579
- grant_type: "authorization_code",
580
- client_id: this.config.clientId,
581
- code,
582
- redirect_uri: redirectUri ?? this.config.redirectUri,
583
- code_verifier: codeVerifier,
584
- });
585
- const res = await fetch(tokenUrl, {
586
- method: "POST",
587
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
588
- body: body.toString(),
589
- });
590
- if (!res.ok) {
591
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
592
- throw new Error(err.error_description || err.error || "Token exchange failed");
593
- }
594
- const tokens = (await res.json());
595
- this.storeTokens(tokens);
596
- return toIAMToken(tokens);
391
+ return await res.json();
392
+ }
393
+ // -----------------------------------------------------------------------
394
+ // URL helpers
395
+ // -----------------------------------------------------------------------
396
+ /** Build the signup URL for the IAM server. */
397
+ getSignupUrl(params) {
398
+ const base = this.config.serverUrl.replace(/\/+$/, "");
399
+ const app = this.config.appName ?? "app";
400
+ this.config.orgName ?? "built-in";
401
+ let url = `${base}/signup/${app}`;
402
+ if (params?.enablePassword) {
403
+ url += "?enablePassword=true";
597
404
  }
598
- /**
599
- * Phone OTP login: tries the numbered username variants Casdoor accepts
600
- * (`{phone}`, `{countryCode}{phone}`), exchanges the resulting code for
601
- * tokens. Returns the token response, or throws on failure.
602
- */
603
- async loginWithPhoneOTP(params) {
604
- const usernames = [params.phone, `${params.countryCode}${params.phone}`];
605
- let lastError = "";
606
- for (const username of usernames) {
607
- const result = await this.loginWithCredentials({
608
- username,
609
- password: params.code,
610
- redirectUri: params.redirectUri,
611
- });
612
- if (result.ok && result.code) {
613
- return this.exchangeCodeForToken(result.code, params.redirectUri);
614
- }
615
- lastError = result.error ?? lastError;
616
- }
617
- throw new Error(lastError || "Phone OTP login failed");
405
+ return url;
406
+ }
407
+ /** Build the user profile URL on the IAM server. */
408
+ getUserProfileUrl(username) {
409
+ const base = this.config.serverUrl.replace(/\/+$/, "");
410
+ const org = this.config.orgName ?? "built-in";
411
+ return `${base}/users/${org}/${username}`;
412
+ }
413
+ // -----------------------------------------------------------------------
414
+ // Casdoor REST surface (signup, OTP, REST login, phone lookup)
415
+ //
416
+ // The OIDC layer covers redirect/PKCE, token exchange, refresh, userinfo.
417
+ // These methods cover the Casdoor-native endpoints that don't have an OIDC
418
+ // analogue: phone/email OTP, custom signup with verification codes, and
419
+ // direct REST login that returns an authorization code in one round-trip
420
+ // (used as the bridge between OTP collection and `exchangeCode`).
421
+ //
422
+ // All paths are gateway-canonical (`/login`, `/signup`,
423
+ // `/send-verification-code`, `/get-phone-user`) — point `serverUrl` at the
424
+ // gateway prefix (e.g. `https://api.dev.satschel.com/v1/iam`) and the
425
+ // gateway proxies to Casdoor's `/api/*` internally.
426
+ // -----------------------------------------------------------------------
427
+ /**
428
+ * Send a verification code to a phone or email destination.
429
+ *
430
+ * @param contact `{ phone, countryCode }` for SMS, `{ email }` for email.
431
+ * @param method Casdoor method: `login`, `signup`, `forget`, `mfaSetup`, etc.
432
+ */
433
+ async sendVerificationCode(contact, method = "login") {
434
+ if (method === "reset") method = "forget";
435
+ const isPhone = "phone" in contact;
436
+ const dest = isPhone ? contact.phone : contact.email;
437
+ const params = {
438
+ applicationId: `admin/${this.config.appName ?? "app"}`,
439
+ dest,
440
+ type: isPhone ? "phone" : "email",
441
+ method,
442
+ captchaType: "none",
443
+ captchaToken: ""
444
+ };
445
+ if (isPhone) params.countryCode = contact.countryCode;
446
+ const url = `${this.config.serverUrl.replace(/\/+$/, "")}/send-verification-code`;
447
+ const res = await fetch(url, {
448
+ method: "POST",
449
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
450
+ body: new URLSearchParams(params).toString()
451
+ });
452
+ const data = await res.json().catch(() => ({}));
453
+ if (data.status === "ok") return { ok: true };
454
+ const msg = data.msg || `send-verification-code failed (${res.status})`;
455
+ if (msg.includes("SMS provider") || msg.includes("provider")) return { ok: true };
456
+ return { ok: false, error: msg };
457
+ }
458
+ /**
459
+ * Look up whether a phone number is registered. Returns `{ exists: false }`
460
+ * on 404 or unknown numbers; `{ exists: true }` when Casdoor confirms a user.
461
+ */
462
+ async lookupPhoneUser(phone, countryCode) {
463
+ const url = `${this.config.serverUrl.replace(/\/+$/, "")}/get-phone-user`;
464
+ const res = await fetch(url, {
465
+ method: "POST",
466
+ headers: { "Content-Type": "application/json" },
467
+ body: JSON.stringify({
468
+ application: this.config.appName ?? "app",
469
+ phone,
470
+ countryCode
471
+ })
472
+ });
473
+ if (res.status === 404) return { exists: false };
474
+ if (!res.ok) return { exists: false, error: `lookupPhoneUser failed (${res.status})` };
475
+ return { exists: true };
476
+ }
477
+ /**
478
+ * Casdoor REST signup. Returns the new user's id on success.
479
+ *
480
+ * Phone signup flow: send phoneCode via `sendVerificationCode`, then call
481
+ * this with the OTP in `phoneCode`. Casdoor verifies the code internally.
482
+ * Email signup flow: same with `email` + `emailCode`.
483
+ */
484
+ async signup(params) {
485
+ const username = params.username ?? params.name;
486
+ const password = params.password ?? `Liq${Date.now()}!`;
487
+ const body = {
488
+ application: this.config.appName ?? "app",
489
+ organization: this.config.orgName ?? "built-in",
490
+ name: params.name,
491
+ username,
492
+ password,
493
+ confirm: password,
494
+ agreement: true
495
+ };
496
+ if (params.method === "email") {
497
+ body.email = params.email;
498
+ if (params.emailCode) body.emailCode = params.emailCode;
499
+ } else {
500
+ body.phone = params.phone;
501
+ body.countryCode = params.countryCode;
502
+ if (params.phoneCode) body.phoneCode = params.phoneCode;
503
+ if (params.email) body.email = params.email;
504
+ if (params.emailCode) body.emailCode = params.emailCode;
618
505
  }
619
- /**
620
- * Logout via Casdoor REST `/logout` (clears server-side session) and
621
- * the local storage.
622
- */
623
- async logout() {
624
- const token = this.storage.getItem(KEY_ACCESS_TOKEN);
625
- try {
626
- await fetch(`${this.config.serverUrl.replace(/\/+$/, "")}/oauth/logout`, {
627
- method: "POST",
628
- headers: token ? { Authorization: `Bearer ${token}` } : {},
629
- });
630
- }
631
- catch {
632
- // best-effort local cleanup is what matters
633
- }
634
- this.clearTokens();
506
+ const url = `${this.config.serverUrl.replace(/\/+$/, "")}/signup`;
507
+ const res = await fetch(url, {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json" },
510
+ body: JSON.stringify(body)
511
+ });
512
+ const data = await res.json().catch(() => ({}));
513
+ if (data.status === "ok") return { ok: true, id: data.data2 || data.data };
514
+ return { ok: false, error: data.msg || `signup failed (${res.status})` };
515
+ }
516
+ /**
517
+ * REST login that returns an authorization code (Casdoor `/login`).
518
+ *
519
+ * Use this when you want the caller to drive the PKCE flow without a
520
+ * full redirect — collect credentials in your own UI, get a code back,
521
+ * then call `exchangeCodeForToken` to land tokens.
522
+ */
523
+ async loginWithCredentials(params) {
524
+ const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
525
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
526
+ const url = new URL(`${this.config.serverUrl.replace(/\/+$/, "")}/login`);
527
+ url.searchParams.set("code_challenge", codeChallenge);
528
+ url.searchParams.set("code_challenge_method", "S256");
529
+ const res = await fetch(url.toString(), {
530
+ method: "POST",
531
+ headers: { "Content-Type": "application/json" },
532
+ body: JSON.stringify({
533
+ application: this.config.appName ?? "app",
534
+ organization: this.config.orgName ?? "built-in",
535
+ username: params.username,
536
+ password: params.password,
537
+ type: params.type ?? "code",
538
+ clientId: this.config.clientId,
539
+ redirectUri: params.redirectUri ?? this.config.redirectUri,
540
+ codeChallenge,
541
+ codeChallengeMethod: "S256",
542
+ autoSignin: true
543
+ })
544
+ });
545
+ const data = await res.json().catch(() => ({}));
546
+ if (data.status === "ok" && data.data) return { ok: true, code: data.data };
547
+ return { ok: false, error: data.msg || `login failed (${res.status})` };
548
+ }
549
+ /**
550
+ * Exchange an authorization code for tokens using the stored PKCE verifier.
551
+ * Pairs with `loginWithCredentials` for a code → tokens round-trip.
552
+ */
553
+ async exchangeCodeForToken(code, redirectUri) {
554
+ const codeVerifier = this.storage.getItem(KEY_CODE_VERIFIER);
555
+ if (!codeVerifier) {
556
+ throw new Error("Missing PKCE verifier \u2014 call loginWithCredentials() first");
635
557
  }
636
- // -----------------------------------------------------------------------
637
- // High-level helpers normalize to ergonomic types so apps don't need
638
- // their own adapters around the OIDC/Casdoor wire shapes.
639
- // -----------------------------------------------------------------------
640
- /**
641
- * Build a social-login authorize URL. Used to navigate the user to
642
- * Google/Apple/etc. — same as `signinRedirect` but returns the URL
643
- * instead of issuing the redirect, so apps can `<a href="...">`.
644
- */
645
- async getSocialLoginUrl(provider, scope = "openid profile email") {
646
- const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
647
- const state = generateState();
648
- this.storage.setItem(KEY_STATE, state);
649
- this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
650
- const discovery = await this.getDiscovery();
651
- const url = new URL(discovery.authorization_endpoint);
652
- url.searchParams.set("client_id", this.config.clientId);
653
- url.searchParams.set("response_type", "code");
654
- url.searchParams.set("redirect_uri", this.config.redirectUri);
655
- url.searchParams.set("scope", scope);
656
- url.searchParams.set("state", state);
657
- url.searchParams.set("code_challenge", codeChallenge);
658
- url.searchParams.set("code_challenge_method", "S256");
659
- url.searchParams.set("provider", provider);
660
- return url.toString();
558
+ this.storage.removeItem(KEY_CODE_VERIFIER);
559
+ const discovery = await this.getDiscovery();
560
+ const tokenUrl = this.config.proxyBaseUrl ? `${this.config.proxyBaseUrl.replace(/\/+$/, "")}/auth/token` : discovery.token_endpoint;
561
+ const body = new URLSearchParams({
562
+ grant_type: "authorization_code",
563
+ client_id: this.config.clientId,
564
+ code,
565
+ redirect_uri: redirectUri ?? this.config.redirectUri,
566
+ code_verifier: codeVerifier
567
+ });
568
+ const res = await fetch(tokenUrl, {
569
+ method: "POST",
570
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
571
+ body: body.toString()
572
+ });
573
+ if (!res.ok) {
574
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
575
+ throw new Error(err.error_description || err.error || "Token exchange failed");
661
576
  }
662
- /**
663
- * Fetch the current user, shaped into the canonical `IAMUser` form
664
- * (camelCase, no `_` keys). Returns null when no token is present.
665
- */
666
- async getUser() {
667
- const token = await this.getValidAccessToken();
668
- if (!token)
669
- return null;
670
- const u = await this.getUserInfo();
671
- return {
672
- sub: u.sub ?? "",
673
- email: u.email,
674
- name: u.name,
675
- givenName: u.given_name,
676
- familyName: u.family_name,
677
- phoneNumber: u.phone_number,
678
- emailVerified: u.email_verified,
679
- picture: u.picture,
680
- owner: u.owner,
681
- };
577
+ const tokens = await res.json();
578
+ this.storeTokens(tokens);
579
+ return toIAMToken(tokens);
580
+ }
581
+ /**
582
+ * Phone OTP login: tries the numbered username variants Casdoor accepts
583
+ * (`{phone}`, `{countryCode}{phone}`), exchanges the resulting code for
584
+ * tokens. Returns the token response, or throws on failure.
585
+ */
586
+ async loginWithPhoneOTP(params) {
587
+ const usernames = [params.phone, `${params.countryCode}${params.phone}`];
588
+ let lastError = "";
589
+ for (const username of usernames) {
590
+ const result = await this.loginWithCredentials({
591
+ username,
592
+ password: params.code,
593
+ redirectUri: params.redirectUri
594
+ });
595
+ if (result.ok && result.code) {
596
+ return this.exchangeCodeForToken(result.code, params.redirectUri);
597
+ }
598
+ lastError = result.error ?? lastError;
682
599
  }
683
- }
684
- /** Convert the raw OAuth2 token response into the canonical `IAMToken`. */
685
- export function toIAMToken(t) {
600
+ throw new Error(lastError || "Phone OTP login failed");
601
+ }
602
+ /**
603
+ * Logout via Casdoor REST `/logout` (clears server-side session) and
604
+ * the local storage.
605
+ */
606
+ async logout() {
607
+ const token = this.storage.getItem(KEY_ACCESS_TOKEN);
608
+ try {
609
+ await fetch(`${this.config.serverUrl.replace(/\/+$/, "")}/oauth/logout`, {
610
+ method: "POST",
611
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
612
+ });
613
+ } catch {
614
+ }
615
+ this.clearTokens();
616
+ }
617
+ // -----------------------------------------------------------------------
618
+ // High-level helpers — normalize to ergonomic types so apps don't need
619
+ // their own adapters around the OIDC/Casdoor wire shapes.
620
+ // -----------------------------------------------------------------------
621
+ /**
622
+ * Build a social-login authorize URL. Used to navigate the user to
623
+ * Google/Apple/etc. — same as `signinRedirect` but returns the URL
624
+ * instead of issuing the redirect, so apps can `<a href="...">`.
625
+ */
626
+ async getSocialLoginUrl(provider, scope = "openid profile email") {
627
+ const { codeVerifier, codeChallenge } = await generatePKCEChallenge();
628
+ const state = generateState();
629
+ this.storage.setItem(KEY_STATE, state);
630
+ this.storage.setItem(KEY_CODE_VERIFIER, codeVerifier);
631
+ const discovery = await this.getDiscovery();
632
+ const url = new URL(discovery.authorization_endpoint);
633
+ url.searchParams.set("client_id", this.config.clientId);
634
+ url.searchParams.set("response_type", "code");
635
+ url.searchParams.set("redirect_uri", this.config.redirectUri);
636
+ url.searchParams.set("scope", scope);
637
+ url.searchParams.set("state", state);
638
+ url.searchParams.set("code_challenge", codeChallenge);
639
+ url.searchParams.set("code_challenge_method", "S256");
640
+ url.searchParams.set("provider", provider);
641
+ return url.toString();
642
+ }
643
+ /**
644
+ * Fetch the current user, shaped into the canonical `IAMUser` form
645
+ * (camelCase, no `_` keys). Returns null when no token is present.
646
+ */
647
+ async getUser() {
648
+ const token = await this.getValidAccessToken();
649
+ if (!token) return null;
650
+ const u = await this.getUserInfo();
686
651
  return {
687
- accessToken: t.access_token,
688
- refreshToken: t.refresh_token,
689
- idToken: t.id_token,
690
- expiresIn: t.expires_in,
691
- tokenType: t.token_type,
692
- scope: t.scope,
652
+ sub: u.sub ?? "",
653
+ email: u.email,
654
+ name: u.name,
655
+ givenName: u.given_name,
656
+ familyName: u.family_name,
657
+ phoneNumber: u.phone_number,
658
+ emailVerified: u.email_verified,
659
+ picture: u.picture,
660
+ owner: u.owner
693
661
  };
662
+ }
663
+ };
664
+ function toIAMToken(t) {
665
+ return {
666
+ accessToken: t.access_token,
667
+ refreshToken: t.refresh_token,
668
+ idToken: t.id_token,
669
+ expiresIn: t.expires_in,
670
+ tokenType: t.token_type,
671
+ scope: t.scope
672
+ };
694
673
  }
674
+
675
+ export { IAM, toIAMToken };
676
+ //# sourceMappingURL=browser.js.map
695
677
  //# sourceMappingURL=browser.js.map