@coreiam/auth 1.0.392-beta.4 → 1.0.392-beta.8

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.
@@ -136,10 +136,47 @@ export declare class CoreIAM {
136
136
  loginWithCsrfProtection(identifier: string, password: string, incomingHeaders: Headers): Promise<Response>;
137
137
  registerWithCsrfProtection(userData: any, incomingHeaders: Headers): Promise<Response>;
138
138
  updateProfile(updateData: any, incomingHeaders: Headers): Promise<Response>;
139
+ changePassword(currentPassword: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
139
140
  setupMfa(incomingHeaders: Headers): Promise<Response>;
140
141
  verifyMfa(token: string, incomingHeaders: Headers): Promise<Response>;
141
142
  disableMfa(incomingHeaders: Headers): Promise<Response>;
142
- requestOtp(body: any, incomingHeaders: Headers): Promise<Response>;
143
- verifyOtp(body: any, incomingHeaders: Headers): Promise<Response>;
143
+ requestOtp(body: RequestOtpBody, incomingHeaders: Headers): Promise<Response>;
144
+ verifyOtp(body: VerifyOtpBody, incomingHeaders: Headers): Promise<Response>;
145
+ forgotPassword(email: string, incomingHeaders: Headers): Promise<Response>;
146
+ resetPassword(email: string, otp: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
147
+ listSessions(incomingHeaders: Headers): Promise<Response>;
148
+ revokeSession(sessionId: string, incomingHeaders: Headers): Promise<Response>;
149
+ revokeAllSessions(incomingHeaders: Headers): Promise<Response>;
144
150
  }
145
151
  export declare const CSRF_PROTECTED_PATHS: ReadonlySet<string>;
152
+ export declare const OAUTH_PROVIDERS: ReadonlySet<string>;
153
+ export interface RequestOtpBody {
154
+ email: string;
155
+ }
156
+ export interface VerifyOtpBody {
157
+ email: string;
158
+ otp: string;
159
+ }
160
+ export interface RequestResetBody {
161
+ email: string;
162
+ }
163
+ export interface ResetPasswordBody {
164
+ email: string;
165
+ otp: string;
166
+ newPassword: string;
167
+ }
168
+ export interface SessionSummary {
169
+ id: string;
170
+ userAgent: string;
171
+ ipAddress: string;
172
+ createdAt: string;
173
+ lastActiveAt: string;
174
+ isCurrent: boolean;
175
+ }
176
+ export interface ListSessionsResponse {
177
+ sessions: SessionSummary[];
178
+ }
179
+ export interface RevokeAllSessionsResponse {
180
+ success: boolean;
181
+ sessionsRevoked: number;
182
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CSRF_PROTECTED_PATHS = exports.CoreIAM = void 0;
3
+ exports.OAUTH_PROVIDERS = exports.CSRF_PROTECTED_PATHS = exports.CoreIAM = void 0;
4
4
  exports.toIdentity = toIdentity;
5
5
  // src/core/index.ts
6
6
  const jose_1 = require("jose");
@@ -339,8 +339,22 @@ class CoreIAM {
339
339
  const existingCookieStr = incomingHeaders.get('cookie') || '';
340
340
  const newCookiePairs = csrfCookies.map(c => c.split(';')[0]);
341
341
  if (newCookiePairs.length > 0) {
342
- const combinedCookies = [existingCookieStr, ...newCookiePairs].filter(Boolean).join('; ');
343
- authHeaders.set('cookie', combinedCookies);
342
+ // FIX (SDK-4): dedupe by cookie NAME, newest value wins. The old
343
+ // [existing, ...new].join() emitted 'csrfToken=old; csrfToken=new' once
344
+ // the browser jar already held a csrf cookie and the handshake rotated
345
+ // it — whether the backend parser reads the first or last duplicate
346
+ // decided 403-or-pass. Nondeterministic across parsers is a bug.
347
+ const jar = new Map();
348
+ for (const pair of [...existingCookieStr.split(';'), ...newCookiePairs]) {
349
+ const trimmed = pair.trim();
350
+ if (!trimmed)
351
+ continue;
352
+ const eq = trimmed.indexOf('=');
353
+ if (eq === -1)
354
+ continue;
355
+ jar.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
356
+ }
357
+ authHeaders.set('cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '));
344
358
  }
345
359
  return this.proxy(path, init, authHeaders);
346
360
  }
@@ -365,6 +379,13 @@ class CoreIAM {
365
379
  body: JSON.stringify(updateData),
366
380
  }, incomingHeaders);
367
381
  }
382
+ async changePassword(currentPassword, newPassword, incomingHeaders) {
383
+ return this.proxyWithCsrfProtection('/auth/password', {
384
+ method: 'PUT',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ currentPassword, newPassword }),
387
+ }, incomingHeaders);
388
+ }
368
389
  async setupMfa(incomingHeaders) {
369
390
  return this.proxyWithCsrfProtection('/auth/mfa/setup', { method: 'POST' }, incomingHeaders);
370
391
  }
@@ -392,10 +413,63 @@ class CoreIAM {
392
413
  body: JSON.stringify(body),
393
414
  }, incomingHeaders);
394
415
  }
416
+ // W3 beta.6: forgot-password / reset-password. Both go through the same
417
+ // CSRF funnel as every other CsrfGuard-decorated endpoint — the paths are
418
+ // pre-registered in CSRF_PROTECTED_PATHS (SDK-6), so the middleware proxy
419
+ // and these methods stay in parity.
420
+ async forgotPassword(email, incomingHeaders) {
421
+ return this.proxyWithCsrfProtection('/auth/request-reset', {
422
+ method: 'POST',
423
+ headers: { 'Content-Type': 'application/json' },
424
+ body: JSON.stringify({ email }),
425
+ }, incomingHeaders);
426
+ }
427
+ async resetPassword(email, otp, newPassword, incomingHeaders) {
428
+ return this.proxyWithCsrfProtection('/auth/reset-password', {
429
+ method: 'POST',
430
+ headers: { 'Content-Type': 'application/json' },
431
+ body: JSON.stringify({ email, otp, newPassword }),
432
+ }, incomingHeaders);
433
+ }
434
+ // W4 (beta.7): session management surface. listSessions is a plain
435
+ // authenticated GET (same shape as the /auth/profile GET precedent —
436
+ // JWT comes from the forwarded cookie via proxy(), reads carry no CSRF
437
+ // requirement); the two state-changing calls go through the shared CSRF
438
+ // handshake exactly like changePassword.
439
+ async listSessions(incomingHeaders) {
440
+ return this.proxy('/auth/sessions', { method: 'GET' }, incomingHeaders);
441
+ }
442
+ async revokeSession(sessionId, incomingHeaders) {
443
+ // Same rationale as OAUTH_PROVIDERS (SDK-5): never interpolate an
444
+ // unvalidated path segment into a URL. The backend's ParseUUIDPipe
445
+ // would reject garbage with a 400 anyway — this fails fast
446
+ // client-side and can never become a traversal string.
447
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
448
+ throw new Error('revokeSession: sessionId must be a UUID');
449
+ }
450
+ return this.proxyWithCsrfProtection(`/auth/sessions/${sessionId}`, { method: 'DELETE' }, incomingHeaders);
451
+ }
452
+ async revokeAllSessions(incomingHeaders) {
453
+ return this.proxyWithCsrfProtection('/auth/sessions/revoke-all', { method: 'POST' }, incomingHeaders);
454
+ }
395
455
  }
396
456
  exports.CoreIAM = CoreIAM;
397
457
  exports.CSRF_PROTECTED_PATHS = new Set([
398
458
  '/auth/login', '/auth/register', '/auth/discover-tenants',
399
459
  '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login',
400
460
  '/auth/profile', '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
461
+ '/auth/password',
462
+ '/auth/request-reset', '/auth/reset-password',
463
+ // W4 (beta.7): revoke-all is exact-matched. The per-session revoke is a
464
+ // DYNAMIC path (/auth/sessions/<uuid>) a string Set cannot match — the SDK
465
+ // methods self-handshake via proxyWithCsrfProtection so server-action
466
+ // callers are fully covered; middleware-proxied browser-direct DELETEs to
467
+ // /api/auth/sessions/<uuid> are not auto-handshaked (known limitation; the
468
+ // fix is a prefix matcher inside middleware.ts, deliberately deferred to
469
+ // keep this patch off that file).
470
+ '/auth/sessions/revoke-all',
401
471
  ]);
472
+ // FIX (SDK-5): single source of truth for supported OAuth providers. Every
473
+ // place that interpolates a provider segment into a backend URL must check
474
+ // this set first — an unvalidated path segment is a traversal surface.
475
+ exports.OAUTH_PROVIDERS = new Set(['google', 'github']);
@@ -1,4 +1,4 @@
1
- import { CoreIAMIdentity } from '../core';
1
+ import { CoreIAMIdentity, RequestOtpBody, VerifyOtpBody } from '../core';
2
2
  export interface AuthResult {
3
3
  ok: boolean;
4
4
  status: number;
@@ -9,8 +9,14 @@ export declare function loginActionSdk(identifier: string, password: string): Pr
9
9
  export declare function registerActionSdk(userData: any): Promise<AuthResult>;
10
10
  export declare function logoutActionSdk(): Promise<AuthResult>;
11
11
  export declare function updateProfileActionSdk(updateData: Record<string, any>): Promise<AuthResult>;
12
+ export declare function changePasswordActionSdk(currentPassword: string, newPassword: string): Promise<AuthResult>;
12
13
  export declare function setupMfaActionSdk(): Promise<AuthResult>;
13
14
  export declare function verifyMfaActionSdk(token: string): Promise<AuthResult>;
14
15
  export declare function disableMfaActionSdk(): Promise<AuthResult>;
15
- export declare function requestOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
16
- export declare function verifyOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
16
+ export declare function requestOtpActionSdk(body: RequestOtpBody): Promise<AuthResult>;
17
+ export declare function verifyOtpActionSdk(body: VerifyOtpBody): Promise<AuthResult>;
18
+ export declare function forgotPasswordActionSdk(email: string): Promise<AuthResult>;
19
+ export declare function resetPasswordActionSdk(email: string, otp: string, newPassword: string): Promise<AuthResult>;
20
+ export declare function listSessionsActionSdk(): Promise<AuthResult>;
21
+ export declare function revokeSessionActionSdk(sessionId: string): Promise<AuthResult>;
22
+ export declare function revokeAllSessionsActionSdk(): Promise<AuthResult>;
@@ -99,11 +99,17 @@ exports.loginActionSdk = loginActionSdk;
99
99
  exports.registerActionSdk = registerActionSdk;
100
100
  exports.logoutActionSdk = logoutActionSdk;
101
101
  exports.updateProfileActionSdk = updateProfileActionSdk;
102
+ exports.changePasswordActionSdk = changePasswordActionSdk;
102
103
  exports.setupMfaActionSdk = setupMfaActionSdk;
103
104
  exports.verifyMfaActionSdk = verifyMfaActionSdk;
104
105
  exports.disableMfaActionSdk = disableMfaActionSdk;
105
106
  exports.requestOtpActionSdk = requestOtpActionSdk;
106
107
  exports.verifyOtpActionSdk = verifyOtpActionSdk;
108
+ exports.forgotPasswordActionSdk = forgotPasswordActionSdk;
109
+ exports.resetPasswordActionSdk = resetPasswordActionSdk;
110
+ exports.listSessionsActionSdk = listSessionsActionSdk;
111
+ exports.revokeSessionActionSdk = revokeSessionActionSdk;
112
+ exports.revokeAllSessionsActionSdk = revokeAllSessionsActionSdk;
107
113
  const headers_1 = require("next/headers");
108
114
  const core_1 = require("../core");
109
115
  // FIX: lazy singleton instead of `const iam = new CoreIAM();` at module
@@ -210,34 +216,13 @@ async function getSession() {
210
216
  return null;
211
217
  }
212
218
  }
213
- // Fallback: If middleware didn't run (e.g. API route), we can still read the JWT
214
- // But we DO NOT verify it here, because verification requires the JWKS fetch which
215
- // is too slow for Server Components. We just decode it to get the identity.
216
- // The middleware is the true security gate.
217
- const authHeader = reqHeaders.get('Authorization') || reqHeaders.get('authorization');
218
- const cookieHeader = reqHeaders.get('cookie');
219
- let token = undefined;
220
- if (authHeader?.startsWith('Bearer ')) {
221
- token = authHeader.substring(7);
222
- }
223
- else if (cookieHeader) {
224
- const match = cookieHeader.match(/jwt=([^;]+)/);
225
- if (match)
226
- token = match[1];
227
- }
228
- if (!token)
229
- return null;
219
+ // FIX (SDK-1): delegate to the SAME verification funnel coreIamMiddleware
220
+ // uses (CoreIAM.verifySession — jose, RS256-pinned, issuer-checked) instead
221
+ // of decoding the payload unverified. One implementation, zero drift,
222
+ // no new env vars. Forged tokens -> null.
230
223
  try {
231
- // Just decode the payload without verifying (middleware already verified it)
232
- const payloadB64 = token.split('.')[1];
233
- const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
234
- const payload = JSON.parse(payloadStr);
235
- return {
236
- sub: payload.sub,
237
- email: payload.email,
238
- tenantId: payload.tenantId,
239
- roles: payload.roles || []
240
- };
224
+ const result = await getIam().verifySession(reqHeaders);
225
+ return result.identity ?? null;
241
226
  }
242
227
  catch {
243
228
  return null;
@@ -291,6 +276,16 @@ async function updateProfileActionSdk(updateData) {
291
276
  return { ok: false, status: 500, data: { error: 'Profile update failed' } };
292
277
  }
293
278
  }
279
+ async function changePasswordActionSdk(currentPassword, newPassword) {
280
+ const reqHeaders = await (0, headers_1.headers)();
281
+ try {
282
+ const response = await getIam().changePassword(currentPassword, newPassword, reqHeaders);
283
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
284
+ }
285
+ catch (error) {
286
+ return { ok: false, status: 500, data: { error: 'Password change failed' } };
287
+ }
288
+ }
294
289
  async function setupMfaActionSdk() {
295
290
  const reqHeaders = await (0, headers_1.headers)();
296
291
  try {
@@ -325,6 +320,10 @@ async function requestOtpActionSdk(body) {
325
320
  const reqHeaders = await (0, headers_1.headers)();
326
321
  try {
327
322
  const response = await getIam().requestOtp(body, reqHeaders);
323
+ // FIX (SDK-3, uniformity): request-otp currently sets no cookies, but routing
324
+ // any Set-Cookie through the same sync as every other auth action keeps the
325
+ // invariant above true if the backend ever starts setting one.
326
+ await syncCookiesToNext(response);
328
327
  return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
329
328
  }
330
329
  catch (error) {
@@ -335,9 +334,80 @@ async function verifyOtpActionSdk(body) {
335
334
  const reqHeaders = await (0, headers_1.headers)();
336
335
  try {
337
336
  const response = await getIam().verifyOtp(body, reqHeaders);
337
+ // FIX (SDK-3): sync Set-Cookie to the Next.js cookie store before returning.
338
+ // W2's POST /auth/verify-otp mints an OTP session and Set-Cookies the jwt
339
+ // cookie (Max-Age 900 — verified live in acceptance). Without this line the
340
+ // action silently dropped the session cookie, so passwordless login via
341
+ // server action never established a browser session. Mirrors the
342
+ // loginActionSdk / registerActionSdk pattern exactly.
343
+ await syncCookiesToNext(response);
338
344
  return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
339
345
  }
340
346
  catch (error) {
341
347
  return { ok: false, status: 500, data: { error: 'OTP verification failed' } };
342
348
  }
343
349
  }
350
+ async function forgotPasswordActionSdk(email) {
351
+ const reqHeaders = await (0, headers_1.headers)();
352
+ try {
353
+ const response = await getIam().forgotPassword(email, reqHeaders);
354
+ await syncCookiesToNext(response);
355
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
356
+ }
357
+ catch (error) {
358
+ return { ok: false, status: 500, data: { error: 'Password reset request failed' } };
359
+ }
360
+ }
361
+ async function resetPasswordActionSdk(email, otp, newPassword) {
362
+ const reqHeaders = await (0, headers_1.headers)();
363
+ try {
364
+ const response = await getIam().resetPassword(email, otp, newPassword, reqHeaders);
365
+ // W2's reset-password revokes ALL session families server-side
366
+ // (revokeAllUserSessions) and clears cookies via Set-Cookie deletions —
367
+ // this sync applies them to the browser. Without it the client would sit
368
+ // on dead cookies after a successful reset.
369
+ await syncCookiesToNext(response);
370
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
371
+ }
372
+ catch (error) {
373
+ return { ok: false, status: 500, data: { error: 'Password reset failed' } };
374
+ }
375
+ }
376
+ // --- W4 (beta.7): session management. listSessions in a Server Component,
377
+ // revoke calls from client components via server actions — same funnel and
378
+ // same AuthResult contract as every action above. The backend sets no
379
+ // cookies on these routes, but Set-Cookie sync stays wired for uniformity
380
+ // (SDK-3 pattern).
381
+ async function listSessionsActionSdk() {
382
+ const reqHeaders = await (0, headers_1.headers)();
383
+ try {
384
+ const response = await getIam().listSessions(reqHeaders);
385
+ await syncCookiesToNext(response);
386
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
387
+ }
388
+ catch (error) {
389
+ return { ok: false, status: 500, data: { error: 'Failed to list sessions' } };
390
+ }
391
+ }
392
+ async function revokeSessionActionSdk(sessionId) {
393
+ const reqHeaders = await (0, headers_1.headers)();
394
+ try {
395
+ const response = await getIam().revokeSession(sessionId, reqHeaders);
396
+ await syncCookiesToNext(response);
397
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
398
+ }
399
+ catch (error) {
400
+ return { ok: false, status: 500, data: { error: error instanceof Error ? error.message : 'Session revoke failed' } };
401
+ }
402
+ }
403
+ async function revokeAllSessionsActionSdk() {
404
+ const reqHeaders = await (0, headers_1.headers)();
405
+ try {
406
+ const response = await getIam().revokeAllSessions(reqHeaders);
407
+ await syncCookiesToNext(response);
408
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
409
+ }
410
+ catch (error) {
411
+ return { ok: false, status: 500, data: { error: 'Revoke-all failed' } };
412
+ }
413
+ }
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { CoreIAM, CoreIAMIdentity } from '../core';
3
3
  export { CoreIAMProvider, useAuth } from './provider';
4
4
  export { coreIamMiddleware, coreIamAuth } from './middleware';
5
- export { getSession, loginActionSdk, registerActionSdk, logoutActionSdk, updateProfileActionSdk, setupMfaActionSdk, verifyMfaActionSdk, disableMfaActionSdk, requestOtpActionSdk, verifyOtpActionSdk } from './actions';
5
+ export { getSession, loginActionSdk, registerActionSdk, logoutActionSdk, updateProfileActionSdk, setupMfaActionSdk, verifyMfaActionSdk, disableMfaActionSdk, requestOtpActionSdk, verifyOtpActionSdk, forgotPasswordActionSdk, resetPasswordActionSdk, listSessionsActionSdk, revokeSessionActionSdk, revokeAllSessionsActionSdk } from './actions';
6
6
  export declare class NextHandlers {
7
7
  private iam;
8
8
  constructor(iam?: CoreIAM);
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OAuthHandlers = exports.NextHandlers = exports.verifyOtpActionSdk = exports.requestOtpActionSdk = exports.disableMfaActionSdk = exports.verifyMfaActionSdk = exports.setupMfaActionSdk = exports.updateProfileActionSdk = exports.logoutActionSdk = exports.registerActionSdk = exports.loginActionSdk = exports.getSession = exports.coreIamAuth = exports.coreIamMiddleware = exports.useAuth = exports.CoreIAMProvider = void 0;
3
+ exports.OAuthHandlers = exports.NextHandlers = exports.revokeAllSessionsActionSdk = exports.revokeSessionActionSdk = exports.listSessionsActionSdk = exports.resetPasswordActionSdk = exports.forgotPasswordActionSdk = exports.verifyOtpActionSdk = exports.requestOtpActionSdk = exports.disableMfaActionSdk = exports.verifyMfaActionSdk = exports.setupMfaActionSdk = exports.updateProfileActionSdk = exports.logoutActionSdk = exports.registerActionSdk = exports.loginActionSdk = exports.getSession = exports.coreIamAuth = exports.coreIamMiddleware = exports.useAuth = exports.CoreIAMProvider = void 0;
4
4
  exports.auth = auth;
5
5
  // src/nextjs/index.ts
6
6
  const server_1 = require("next/server");
@@ -23,6 +23,11 @@ Object.defineProperty(exports, "verifyMfaActionSdk", { enumerable: true, get: fu
23
23
  Object.defineProperty(exports, "disableMfaActionSdk", { enumerable: true, get: function () { return actions_1.disableMfaActionSdk; } });
24
24
  Object.defineProperty(exports, "requestOtpActionSdk", { enumerable: true, get: function () { return actions_1.requestOtpActionSdk; } });
25
25
  Object.defineProperty(exports, "verifyOtpActionSdk", { enumerable: true, get: function () { return actions_1.verifyOtpActionSdk; } });
26
+ Object.defineProperty(exports, "forgotPasswordActionSdk", { enumerable: true, get: function () { return actions_1.forgotPasswordActionSdk; } });
27
+ Object.defineProperty(exports, "resetPasswordActionSdk", { enumerable: true, get: function () { return actions_1.resetPasswordActionSdk; } });
28
+ Object.defineProperty(exports, "listSessionsActionSdk", { enumerable: true, get: function () { return actions_1.listSessionsActionSdk; } });
29
+ Object.defineProperty(exports, "revokeSessionActionSdk", { enumerable: true, get: function () { return actions_1.revokeSessionActionSdk; } });
30
+ Object.defineProperty(exports, "revokeAllSessionsActionSdk", { enumerable: true, get: function () { return actions_1.revokeAllSessionsActionSdk; } });
26
31
  class NextHandlers {
27
32
  iam;
28
33
  constructor(iam) {
@@ -62,6 +67,10 @@ class OAuthHandlers {
62
67
  }
63
68
  // Handles the initial redirect to Google/GitHub
64
69
  async handleInitiate(req, provider) {
70
+ // FIX (SDK-5): validate the provider segment before interpolation.
71
+ if (!core_1.OAUTH_PROVIDERS.has(provider)) {
72
+ return server_1.NextResponse.redirect(new URL('/auth/login', req.url));
73
+ }
65
74
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
66
75
  const response = await this.iam.proxy(`/auth/oauth/initiate/${provider}?redirectUri=${encodeURIComponent(redirectUri)}&scopes=email,profile`, { method: 'GET' }, req.headers);
67
76
  const data = await response.json();
@@ -72,11 +81,19 @@ class OAuthHandlers {
72
81
  }
73
82
  // Handles the callback from Google/GitHub
74
83
  async handleCallback(req, provider) {
84
+ // FIX (SDK-5): validate the provider segment before interpolation.
85
+ if (!core_1.OAUTH_PROVIDERS.has(provider)) {
86
+ return server_1.NextResponse.redirect(new URL('/auth/login', req.url));
87
+ }
75
88
  const { searchParams } = new URL(req.url);
76
89
  const code = searchParams.get('code');
77
90
  const state = searchParams.get('state');
78
91
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
79
- const targetUrl = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${redirectUri}`;
92
+ // FIX (SDK-5): raw code/state/redirectUri here was a query-injection surface.
93
+ const targetUrl = `/auth/oauth/callback/${provider}`
94
+ + `?code=${encodeURIComponent(code ?? '')}`
95
+ + `&state=${encodeURIComponent(state ?? '')}`
96
+ + `&redirectUri=${encodeURIComponent(redirectUri)}`;
80
97
  const response = await this.iam.proxy(targetUrl, { method: 'GET' }, req.headers);
81
98
  const res = server_1.NextResponse.redirect(new URL('/', req.url));
82
99
  const setCookies = response.headers.getSetCookie();
@@ -89,8 +106,39 @@ class OAuthHandlers {
89
106
  }
90
107
  }
91
108
  exports.OAuthHandlers = OAuthHandlers;
109
+ // FIX (SDK-7): bounded LRU instead of wholesale .clear() at 10k. Flushing the
110
+ // whole map on overflow let an attacker cycle 10,001 distinct fake Bearer
111
+ // tokens and evict every LEGIT entry — all real users' next requests re-hit
112
+ // the backend verify (cache-stampede amplifier). LRU keeps hot legit entries
113
+ // alive under exactly that attack. Hand-rolled on purpose: zero new
114
+ // dependencies for a published SDK; semantics fully covered by vitest.
92
115
  const MAX_CACHE_SIZE = 10000;
116
+ const CACHE_TTL_MS = 5 * 60 * 1000;
93
117
  const apiKeyCache = new Map();
118
+ // LRU read: drop stale entries, and delete+re-set moves the entry to the
119
+ // Map's tail — JavaScript Maps preserve insertion order, so the tail is
120
+ // "most recently used" and the head is "least recently used".
121
+ function cacheGet(token) {
122
+ const entry = apiKeyCache.get(token);
123
+ if (!entry)
124
+ return undefined;
125
+ if (entry.expires <= Date.now()) {
126
+ apiKeyCache.delete(token);
127
+ return undefined;
128
+ }
129
+ apiKeyCache.delete(token);
130
+ apiKeyCache.set(token, entry);
131
+ return entry;
132
+ }
133
+ // LRU write: insert at tail; if over capacity, evict the head (oldest use).
134
+ function cacheSet(token, entry) {
135
+ apiKeyCache.set(token, entry);
136
+ if (apiKeyCache.size > MAX_CACHE_SIZE) {
137
+ const lru = apiKeyCache.keys().next().value;
138
+ if (lru !== undefined)
139
+ apiKeyCache.delete(lru);
140
+ }
141
+ }
94
142
  async function auth(request) {
95
143
  const authHeader = request.headers.get('authorization');
96
144
  if (!authHeader?.startsWith('Bearer '))
@@ -99,15 +147,11 @@ async function auth(request) {
99
147
  const gatewayUrl = process.env.COREIAM_API_URL;
100
148
  if (!gatewayUrl)
101
149
  return null;
102
- // 1. Check Cache
103
- const cached = apiKeyCache.get(token);
104
- if (cached && cached.expires > Date.now()) {
150
+ // 1. Check Cache (was: raw .get() + expiry check + wholesale .clear() on size)
151
+ const cached = cacheGet(token);
152
+ if (cached) {
105
153
  return cached.valid ? cached.identity : null;
106
154
  }
107
- // FIX: Prevent memory leak by clearing cache if it gets too large
108
- if (apiKeyCache.size >= MAX_CACHE_SIZE) {
109
- apiKeyCache.clear(); // Simple crude eviction. In production SDK, use lru-cache.
110
- }
111
155
  // 2. Call CoreIAM to verify
112
156
  try {
113
157
  const res = await fetch(`${gatewayUrl}/auth/api-keys/verify`, {
@@ -124,11 +168,11 @@ async function auth(request) {
124
168
  sub: data.sub,
125
169
  tenantId: data.tenantId,
126
170
  };
127
- apiKeyCache.set(token, { valid: true, identity, expires: Date.now() + (5 * 60 * 1000) });
171
+ cacheSet(token, { valid: true, identity, expires: Date.now() + CACHE_TTL_MS });
128
172
  return identity;
129
173
  }
130
174
  else {
131
- apiKeyCache.set(token, { valid: false, expires: Date.now() + (5 * 60 * 1000) });
175
+ cacheSet(token, { valid: false, expires: Date.now() + CACHE_TTL_MS });
132
176
  return null;
133
177
  }
134
178
  }
@@ -122,7 +122,7 @@ exports.coreIamMiddleware = coreIamMiddleware;
122
122
  // }
123
123
  // return { response: NextResponse.next(), isAuthenticated };
124
124
  // }
125
- // src/nextjs/middleware.ts
125
+ // sdk/javascript/src/nextjs/middleware.ts
126
126
  const server_1 = require("next/server");
127
127
  //import { jwtVerify, createRemoteJWKSet } from 'jose';
128
128
  const core_1 = require("../core");
@@ -153,20 +153,56 @@ async function performRefresh(refreshTokenCookie, jwtCookie) {
153
153
  });
154
154
  if (!refreshRes.ok)
155
155
  return null;
156
+ // FIX (SDK-11): derive Max-Age from the backend's own Set-Cookie instead of
157
+ // hardcoding 24h/7d — the hardcoded values silently overrode the backend's
158
+ // post-W1 policy (jwt Max-Age 900). The backend is the source of truth.
156
159
  const setCookies = refreshRes.headers.getSetCookie();
157
160
  let newJwt = '', newRefreshToken = '';
161
+ let jwtMaxAge = 900, refreshMaxAge = 7 * 24 * 60 * 60; // fallbacks if backend sends no Max-Age
158
162
  setCookies.forEach(cookie => {
159
- if (cookie.startsWith('jwt='))
163
+ const maxAgeMatch = cookie.match(/max-age=(-?\d+)/i);
164
+ if (cookie.startsWith('jwt=')) {
160
165
  newJwt = cookie.split(';')[0].split('=')[1];
161
- if (cookie.startsWith('refreshToken='))
166
+ if (maxAgeMatch)
167
+ jwtMaxAge = parseInt(maxAgeMatch[1], 10);
168
+ }
169
+ if (cookie.startsWith('refreshToken=')) {
162
170
  newRefreshToken = cookie.split(';')[0].split('=')[1];
171
+ if (maxAgeMatch)
172
+ refreshMaxAge = parseInt(maxAgeMatch[1], 10);
173
+ }
163
174
  });
164
- return newJwt ? { jwt: newJwt, refreshToken: newRefreshToken } : null;
175
+ return newJwt ? { jwt: newJwt, refreshToken: newRefreshToken, jwtMaxAge, refreshMaxAge } : null;
165
176
  }
166
177
  catch (error) {
167
178
  return null;
168
179
  }
169
180
  }
181
+ // async function performRefresh(
182
+ // refreshTokenCookie: { value: string } | undefined,
183
+ // jwtCookie: { value: string } | undefined,
184
+ // ): Promise<{ jwt: string; refreshToken: string } | null> {
185
+ // if (!gatewayUrl) return null;
186
+ // try {
187
+ // const refreshRes = await fetch(`${gatewayUrl}/auth/refresh-token`, {
188
+ // method: 'POST',
189
+ // headers: {
190
+ // 'X-API-Key': process.env.COREIAM_API_KEY!,
191
+ // 'cookie': `refreshToken=${refreshTokenCookie?.value || ''}; jwt=${jwtCookie?.value || ''}`,
192
+ // },
193
+ // });
194
+ // if (!refreshRes.ok) return null;
195
+ // const setCookies = refreshRes.headers.getSetCookie();
196
+ // let newJwt = '', newRefreshToken = '';
197
+ // setCookies.forEach(cookie => {
198
+ // if (cookie.startsWith('jwt=')) newJwt = cookie.split(';')[0].split('=')[1];
199
+ // if (cookie.startsWith('refreshToken=')) newRefreshToken = cookie.split(';')[0].split('=')[1];
200
+ // });
201
+ // return newJwt ? { jwt: newJwt, refreshToken: newRefreshToken } : null;
202
+ // } catch (error) {
203
+ // return null;
204
+ // }
205
+ // }
170
206
  /**
171
207
  * Helper function for custom middleware (proxy.ts).
172
208
  * Returns the response and authentication state.
@@ -182,7 +218,12 @@ async function coreIamAuth(request) {
182
218
  if (method === 'OPTIONS') {
183
219
  const res = new server_1.NextResponse(null, { status: 204 }); // 204 No Content
184
220
  const origin = request.headers.get('origin');
185
- if (origin) {
221
+ // FIX (SDK-14): reflect only known origins — reflecting ANY origin together
222
+ // with Allow-Credentials is a footgun even though this only answers
223
+ // preflights (actual responses carry no CORS headers).
224
+ const allowedOrigins = (process.env.COREIAM_ALLOWED_ORIGINS || '')
225
+ .split(',').map(s => s.trim()).filter(Boolean);
226
+ if (origin && (origin === request.nextUrl.origin || allowedOrigins.includes(origin))) {
186
227
  res.headers.set('Access-Control-Allow-Origin', origin);
187
228
  res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
188
229
  res.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token');
@@ -235,7 +276,7 @@ async function coreIamAuth(request) {
235
276
  </head>
236
277
  <body>
237
278
  <p>Account linked successfully! Redirecting...</p>
238
- <script>window.location.href = "${finalUrl}";</script>
279
+ <script>window.location.href = ${JSON.stringify(finalUrl)};</script>
239
280
  </body>
240
281
  </html>
241
282
  `;
@@ -263,14 +304,27 @@ async function coreIamAuth(request) {
263
304
  let targetPath = pathname.replace('/api/auth', '/auth');
264
305
  const body = method !== 'GET' ? await request.json().catch(() => undefined) : undefined;
265
306
  // FIX: Special routing for OAuth Initiate
307
+ // if (pathname.startsWith('/api/auth/oauth/') && !pathname.includes('callback') && !pathname.includes('link')) {
308
+ // const provider = pathname.split('/').pop();
309
+ // const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
310
+ // targetPath = `/auth/oauth/initiate/${provider}?redirectUri=${encodeURIComponent(redirectUri)}&scopes=email,profile`;
311
+ // }
312
+ // FIX: Special routing for OAuth Initiate
266
313
  if (pathname.startsWith('/api/auth/oauth/') && !pathname.includes('callback') && !pathname.includes('link')) {
267
- const provider = pathname.split('/').pop();
314
+ const provider = pathname.split('/').pop() || '';
315
+ // FIX (SDK-5): reject unknown providers before URL interpolation.
316
+ if (!core_1.OAUTH_PROVIDERS.has(provider)) {
317
+ return { response: new server_1.NextResponse('Unsupported OAuth provider', { status: 400 }), isAuthenticated: false };
318
+ }
268
319
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
269
320
  targetPath = `/auth/oauth/initiate/${provider}?redirectUri=${encodeURIComponent(redirectUri)}&scopes=email,profile`;
270
321
  }
271
- // FIX: Special routing for OAuth Callback
272
322
  if (pathname.startsWith('/api/auth/callback/')) {
273
- const provider = pathname.split('/').pop();
323
+ const provider = pathname.split('/').pop() || '';
324
+ // FIX (SDK-5): reject unknown providers before URL interpolation.
325
+ if (!core_1.OAUTH_PROVIDERS.has(provider)) {
326
+ return { response: new server_1.NextResponse('Unsupported OAuth provider', { status: 400 }), isAuthenticated: false };
327
+ }
274
328
  const searchParams = new URL(request.url).searchParams;
275
329
  const code = searchParams.get('code');
276
330
  const state = searchParams.get('state');
@@ -357,6 +411,13 @@ async function coreIamAuth(request) {
357
411
 
358
412
  */
359
413
  // 2. LOCAL JWT VERIFICATION & REFRESH
414
+ // FIX (SDK-9): strip any client-supplied `x-coreiam-identity` header BEFORE
415
+ // any pass-through. getSession() (actions.ts) trusts this header verbatim,
416
+ // and on unauthenticated paths the original request headers flow straight
417
+ // into Server Components — a forged header manufactured an identity there.
418
+ // Only branches that complete verification re-attach it, to this copy.
419
+ const sanitizedHeaders = new Headers(request.headers);
420
+ sanitizedHeaders.delete('x-coreiam-identity');
360
421
  // Now delegates to CoreIAM.verifySession — the same code path Express
361
422
  // users get, so the two implementations can never drift.
362
423
  const jwtCookie = request.cookies.get("jwt");
@@ -366,7 +427,7 @@ async function coreIamAuth(request) {
366
427
  if (client) {
367
428
  const result = await client.verifySession(request.headers);
368
429
  if (result.identity) {
369
- const requestHeaders = new Headers(request.headers);
430
+ const requestHeaders = new Headers(sanitizedHeaders);
370
431
  requestHeaders.set('x-coreiam-identity', JSON.stringify(result.identity));
371
432
  return {
372
433
  response: server_1.NextResponse.next({ request: { headers: requestHeaders } }),
@@ -388,33 +449,56 @@ async function coreIamAuth(request) {
388
449
  }
389
450
  const result = await refreshInFlight;
390
451
  if (result) {
391
- const requestHeaders = new Headers(request.headers);
452
+ const requestHeaders = new Headers(sanitizedHeaders);
392
453
  requestHeaders.set('cookie', `jwt=${result.jwt}; refreshToken=${result.refreshToken}`);
393
454
  requestHeaders.set('Authorization', `Bearer ${result.jwt}`);
394
- try {
395
- const payloadB64 = result.jwt.split('.')[1];
396
- const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
397
- const payload = JSON.parse(payloadStr);
398
- requestHeaders.set('x-coreiam-identity', JSON.stringify({
399
- sub: payload.sub,
400
- email: payload.email,
401
- tenantId: payload.tenantId,
402
- roles: payload.roles || []
403
- }));
455
+ // try {
456
+ // const payloadB64 = result.jwt.split('.')[1];
457
+ // const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
458
+ // const payload = JSON.parse(payloadStr);
459
+ // requestHeaders.set('x-coreiam-identity', JSON.stringify({
460
+ // sub: payload.sub,
461
+ // email: payload.email,
462
+ // tenantId: payload.tenantId,
463
+ // roles: payload.roles || []
464
+ // }));
465
+ // } catch {}
466
+ // // FIX: forward requestHeaders (fresh cookie + identity) — NOT sanitizedHeaders,
467
+ // // which still carries the old expired cookie.
468
+ // const response = NextResponse.next({ request: { headers: requestHeaders } });
469
+ // const isProd = process.env.NODE_ENV === 'production';
470
+ // response.cookies.set('jwt', result.jwt, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 24 * 60 * 60 });
471
+ // response.cookies.set('refreshToken', result.refreshToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 });
472
+ // return { response, isAuthenticated: true };
473
+ // FIX (SDK-12): don't decode the JWT by hand. Buffer isn't available in
474
+ // the Edge runtime (Next middleware's default) — that throw was silently
475
+ // swallowed by the empty catch, leaving x-coreiam-identity missing on Edge.
476
+ // Verifying through the same funnel everything else uses fixes that and
477
+ // keeps ONE verification implementation. verifySession accepts a
478
+ // cookie-header string.
479
+ const verified = await client?.verifySession(`jwt=${result.jwt}`);
480
+ if (verified?.identity) {
481
+ requestHeaders.set('x-coreiam-identity', JSON.stringify(verified.identity));
404
482
  }
405
- catch { }
483
+ // FIX: forward requestHeaders (fresh cookie + identity) — NOT sanitizedHeaders,
484
+ // which still carries the old expired cookie.
406
485
  const response = server_1.NextResponse.next({ request: { headers: requestHeaders } });
407
486
  const isProd = process.env.NODE_ENV === 'production';
408
- response.cookies.set('jwt', result.jwt, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 24 * 60 * 60 });
409
- response.cookies.set('refreshToken', result.refreshToken, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 });
487
+ // FIX (SDK-11): Max-Age derived from the backend's own Set-Cookie (see
488
+ // performRefresh) instead of hardcoded 24h/7d, and sameSite 'lax' to match
489
+ // the link-confirm handler — previously the only lax/strict mismatch.
490
+ response.cookies.set('jwt', result.jwt, { httpOnly: true, secure: isProd, sameSite: 'lax', maxAge: result.jwtMaxAge });
491
+ response.cookies.set('refreshToken', result.refreshToken, { httpOnly: true, secure: isProd, sameSite: 'lax', maxAge: result.refreshMaxAge });
410
492
  return { response, isAuthenticated: true };
411
493
  }
412
- const response = server_1.NextResponse.next();
494
+ // FIX (SDK-9): failure branch must forward sanitizedHeaders too — bare next()
495
+ // passes the original headers through, re-opening the forged-header hole.
496
+ const response = server_1.NextResponse.next({ request: { headers: sanitizedHeaders } });
413
497
  response.cookies.delete('jwt');
414
498
  response.cookies.delete('refreshToken');
415
499
  return { response, isAuthenticated: false };
416
500
  }
417
- return { response: server_1.NextResponse.next(), isAuthenticated: false };
501
+ return { response: server_1.NextResponse.next({ request: { headers: sanitizedHeaders } }), isAuthenticated: false };
418
502
  }
419
503
  /**
420
504
  * 1-line middleware factory for developers who don't need custom routing.
@@ -424,7 +508,10 @@ function coreIamMiddleware(options = {}) {
424
508
  return async (request) => {
425
509
  const { response, isAuthenticated } = await coreIamAuth(request);
426
510
  const pathname = request.nextUrl.pathname;
427
- const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route));
511
+ // const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route));
512
+ // FIX (SDK-15): exact-segment prefix match — '/blog' must not also make
513
+ // '/blog-admin' public. Tolerates routes declared with a trailing slash.
514
+ const isPublicRoute = publicRoutes.some(route => pathname === route || pathname.startsWith(route.endsWith('/') ? route : route + '/'));
428
515
  // If not authenticated and trying to access a protected route
429
516
  if (!isAuthenticated && !isPublicRoute) {
430
517
  const redirect = server_1.NextResponse.redirect(new URL(signInUrl, request.url));
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { CoreIAMIdentity } from '../core';
2
+ import { type CoreIAMIdentity } from '../core';
3
3
  interface AuthContextType {
4
4
  identity: CoreIAMIdentity | null;
5
5
  isLoading: boolean;
@@ -21,6 +21,22 @@ interface AuthContextType {
21
21
  error?: string;
22
22
  }>;
23
23
  refetchIdentity: () => Promise<void>;
24
+ requestOtp: (email: string) => Promise<{
25
+ success?: boolean;
26
+ error?: string;
27
+ }>;
28
+ verifyOtp: (email: string, otp: string) => Promise<{
29
+ success?: boolean;
30
+ error?: string;
31
+ }>;
32
+ forgotPassword: (email: string) => Promise<{
33
+ success?: boolean;
34
+ error?: string;
35
+ }>;
36
+ resetPassword: (email: string, otp: string, newPassword: string) => Promise<{
37
+ success?: boolean;
38
+ error?: string;
39
+ }>;
24
40
  }
25
41
  export declare function CoreIAMProvider({ children, initialIdentity }: {
26
42
  children: React.ReactNode;
@@ -60,6 +60,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
60
60
  const react_1 = require("react");
61
61
  const navigation_1 = require("next/navigation");
62
62
  const actions_1 = require("./actions");
63
+ const core_1 = require("../core");
63
64
  const AuthContext = (0, react_1.createContext)({
64
65
  identity: null,
65
66
  isLoading: true,
@@ -69,6 +70,10 @@ const AuthContext = (0, react_1.createContext)({
69
70
  register: async () => ({ error: 'Not mounted' }),
70
71
  verifyMfa: async () => ({ error: 'Not mounted' }),
71
72
  refetchIdentity: async () => { },
73
+ requestOtp: async () => ({ error: 'Not mounted' }),
74
+ verifyOtp: async () => ({ error: 'Not mounted' }),
75
+ forgotPassword: async () => ({ error: 'Not mounted' }),
76
+ resetPassword: async () => ({ error: 'Not mounted' }),
72
77
  });
73
78
  function CoreIAMProvider({ children, initialIdentity }) {
74
79
  const [identity, setIdentity] = (0, react_1.useState)(initialIdentity ?? null);
@@ -107,9 +112,19 @@ function CoreIAMProvider({ children, initialIdentity }) {
107
112
  const verifyMfa = async (tempToken, code) => {
108
113
  setIsLoading(true);
109
114
  try {
115
+ // FIX (SDK-2): perform the CSRF handshake before calling /api/auth/mfa/login.
116
+ // That path is in CSRF_PROTECTED_PATHS and the backend CsrfGuard is
117
+ // double-submit (the x-csrf-token header must equal the csrfToken cookie).
118
+ // The login-phase handshake already placed the csrf cookie in the browser
119
+ // jar, so only the header echo was missing — which made every provider
120
+ // MFA login fail with 403. This now mirrors the login() flow exactly.
121
+ const csrfToken = await getCsrfToken();
110
122
  const res = await fetch('/api/auth/mfa/login', {
111
123
  method: 'POST',
112
- headers: { 'Content-Type': 'application/json' },
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ 'x-csrf-token': csrfToken,
127
+ },
113
128
  credentials: 'include',
114
129
  body: JSON.stringify({ tempToken, code }),
115
130
  });
@@ -128,6 +143,101 @@ function CoreIAMProvider({ children, initialIdentity }) {
128
143
  setIsLoading(false);
129
144
  }
130
145
  };
146
+ const requestOtp = async (email) => {
147
+ setIsLoading(true);
148
+ try {
149
+ const csrfToken = await getCsrfToken();
150
+ const res = await fetch('/api/auth/request-otp', {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
153
+ credentials: 'include',
154
+ body: JSON.stringify({ email }),
155
+ });
156
+ const data = await res.json();
157
+ if (!res.ok)
158
+ return { error: data.message || 'OTP request failed' };
159
+ return { success: true };
160
+ }
161
+ catch {
162
+ return { error: 'Something went wrong' };
163
+ }
164
+ finally {
165
+ setIsLoading(false);
166
+ }
167
+ };
168
+ const verifyOtp = async (email, otp) => {
169
+ setIsLoading(true);
170
+ try {
171
+ const csrfToken = await getCsrfToken();
172
+ const res = await fetch('/api/auth/verify-otp', {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
175
+ credentials: 'include',
176
+ body: JSON.stringify({ email, otp }),
177
+ });
178
+ const data = await res.json();
179
+ if (!res.ok)
180
+ return { error: data.message || 'Invalid or expired OTP' };
181
+ // Session established (jwt cookie set server-side) — hard refresh to
182
+ // let the server re-render authenticated, same as login()/verifyMfa().
183
+ window.location.href = '/';
184
+ return { success: true };
185
+ }
186
+ catch {
187
+ return { error: 'Something went wrong' };
188
+ }
189
+ finally {
190
+ setIsLoading(false);
191
+ }
192
+ };
193
+ const forgotPassword = async (email) => {
194
+ setIsLoading(true);
195
+ try {
196
+ const csrfToken = await getCsrfToken();
197
+ const res = await fetch('/api/auth/request-reset', {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
200
+ credentials: 'include',
201
+ body: JSON.stringify({ email }),
202
+ });
203
+ const data = await res.json();
204
+ if (!res.ok)
205
+ return { error: data.message || 'Reset request failed' };
206
+ // Backend response is uniform (no account-existence leak) — treat
207
+ // success generically; UI copy ("if the account exists…") is the app's.
208
+ return { success: true };
209
+ }
210
+ catch {
211
+ return { error: 'Something went wrong' };
212
+ }
213
+ finally {
214
+ setIsLoading(false);
215
+ }
216
+ };
217
+ const resetPassword = async (email, otp, newPassword) => {
218
+ setIsLoading(true);
219
+ try {
220
+ const csrfToken = await getCsrfToken();
221
+ const res = await fetch('/api/auth/reset-password', {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
224
+ credentials: 'include',
225
+ body: JSON.stringify({ email, otp, newPassword }),
226
+ });
227
+ const data = await res.json();
228
+ if (!res.ok)
229
+ return { error: data.message || 'Password reset failed' };
230
+ // All sessions revoked server-side — route to login with the new password.
231
+ window.location.href = '/auth/login';
232
+ return { success: true };
233
+ }
234
+ catch {
235
+ return { error: 'Something went wrong' };
236
+ }
237
+ finally {
238
+ setIsLoading(false);
239
+ }
240
+ };
131
241
  const login = async (values, callbackUrl) => {
132
242
  setIsLoading(true);
133
243
  try {
@@ -151,7 +261,20 @@ function CoreIAMProvider({ children, initialIdentity }) {
151
261
  return { twoFactor: true, tempToken: data.tempToken };
152
262
  }
153
263
  // Hard refresh to let server re-render with new cookies
154
- window.location.href = callbackUrl || '/';
264
+ // FIX (SDK-8): defense-in-depth — only same-origin relative callback URLs.
265
+ // Airtight defense-in-depth against open redirects
266
+ const cb = callbackUrl || '/';
267
+ let target;
268
+ try {
269
+ target = new URL(cb, window.location.origin);
270
+ }
271
+ catch {
272
+ return { error: 'Invalid callback URL' };
273
+ }
274
+ if (target.origin !== window.location.origin) {
275
+ return { error: 'Invalid callback URL' };
276
+ }
277
+ window.location.href = target.pathname + target.search + target.hash;
155
278
  return { success: true };
156
279
  }
157
280
  catch {
@@ -192,6 +315,15 @@ function CoreIAMProvider({ children, initialIdentity }) {
192
315
  if (!res.ok) {
193
316
  if (data.code === 'OAUTH_ACCOUNT_EXISTS') {
194
317
  // Automatically redirect to the OAuth provider!
318
+ // FIX (SDK-8): route only to known providers (local literal — importing
319
+ // OAUTH_PROVIDERS from ../core would pull server code into the client bundle).
320
+ if (!['google', 'github'].includes(data.provider)) {
321
+ return { error: 'Unsupported provider' };
322
+ }
323
+ // Use the exported Set from core instead of a hardcoded array to prevent drift
324
+ if (!core_1.OAUTH_PROVIDERS.has(data.provider)) {
325
+ return { error: 'Unsupported provider' };
326
+ }
195
327
  window.location.href = `/api/auth/oauth/${data.provider}`;
196
328
  return { success: true };
197
329
  }
@@ -207,7 +339,7 @@ function CoreIAMProvider({ children, initialIdentity }) {
207
339
  setIsLoading(false);
208
340
  }
209
341
  };
210
- return ((0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { identity, isLoading, isAuthenticated: !!identity, login, logout, register, verifyMfa, refetchIdentity }, children: children }));
342
+ return ((0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { identity, isLoading, isAuthenticated: !!identity, login, logout, register, verifyMfa, refetchIdentity, requestOtp, verifyOtp, forgotPassword, resetPassword }, children: children }));
211
343
  }
212
344
  function useAuth() {
213
345
  return (0, react_1.useContext)(AuthContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreiam/auth",
3
- "version": "1.0.392-beta.4",
3
+ "version": "1.0.392-beta.8",
4
4
  "main": "./dist/core/index.js",
5
5
  "types": "./dist/core/index.d.ts",
6
6
  "exports": {