@coreiam/auth 1.0.392-beta.5 → 1.0.392-beta.9

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.
@@ -41,6 +41,7 @@ export interface RefreshSessionResult {
41
41
  }
42
42
  /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
43
43
  export declare function toIdentity(payload: JWTPayload): CoreIAMIdentity;
44
+ export declare function retryAfterSeconds(body: unknown): number | undefined;
44
45
  /**
45
46
  export class CoreIAM {
46
47
  private apiKey: string = '';
@@ -140,7 +141,49 @@ export declare class CoreIAM {
140
141
  setupMfa(incomingHeaders: Headers): Promise<Response>;
141
142
  verifyMfa(token: string, incomingHeaders: Headers): Promise<Response>;
142
143
  disableMfa(incomingHeaders: Headers): Promise<Response>;
143
- requestOtp(body: any, incomingHeaders: Headers): Promise<Response>;
144
- verifyOtp(body: any, incomingHeaders: Headers): Promise<Response>;
144
+ requestOtp(body: RequestOtpBody, incomingHeaders: Headers): Promise<Response>;
145
+ verifyOtp(body: VerifyOtpBody, incomingHeaders: Headers): Promise<Response>;
146
+ forgotPassword(email: string, incomingHeaders: Headers): Promise<Response>;
147
+ verifyEmail(body: VerifyEmailBody, incomingHeaders: Headers): Promise<Response>;
148
+ resendVerification(email: string, incomingHeaders: Headers): Promise<Response>;
149
+ resetPassword(email: string, otp: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
150
+ listSessions(incomingHeaders: Headers): Promise<Response>;
151
+ revokeSession(sessionId: string, incomingHeaders: Headers): Promise<Response>;
152
+ revokeAllSessions(incomingHeaders: Headers): Promise<Response>;
145
153
  }
146
154
  export declare const CSRF_PROTECTED_PATHS: ReadonlySet<string>;
155
+ export declare const OAUTH_PROVIDERS: ReadonlySet<string>;
156
+ export interface RequestOtpBody {
157
+ email: string;
158
+ }
159
+ export interface VerifyOtpBody {
160
+ email: string;
161
+ otp: string;
162
+ }
163
+ export interface VerifyEmailBody {
164
+ email: string;
165
+ otp: string;
166
+ }
167
+ export interface RequestResetBody {
168
+ email: string;
169
+ }
170
+ export interface ResetPasswordBody {
171
+ email: string;
172
+ otp: string;
173
+ newPassword: string;
174
+ }
175
+ export interface SessionSummary {
176
+ id: string;
177
+ userAgent: string;
178
+ ipAddress: string;
179
+ createdAt: string;
180
+ lastActiveAt: string;
181
+ isCurrent: boolean;
182
+ }
183
+ export interface ListSessionsResponse {
184
+ sessions: SessionSummary[];
185
+ }
186
+ export interface RevokeAllSessionsResponse {
187
+ success: boolean;
188
+ sessionsRevoked: number;
189
+ }
@@ -1,7 +1,8 @@
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
+ exports.retryAfterSeconds = retryAfterSeconds;
5
6
  // src/core/index.ts
6
7
  const jose_1 = require("jose");
7
8
  /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
@@ -13,6 +14,17 @@ function toIdentity(payload) {
13
14
  roles: Array.isArray(payload.roles) ? payload.roles : [],
14
15
  };
15
16
  }
17
+ function retryAfterSeconds(body) {
18
+ const msg = body?.message;
19
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
20
+ if (typeof m === 'string') {
21
+ const match = m.match(/Try again in (\d+) seconds?/);
22
+ if (match)
23
+ return Number(match[1]);
24
+ }
25
+ }
26
+ return undefined;
27
+ }
16
28
  /**
17
29
  export class CoreIAM {
18
30
  private apiKey: string = '';
@@ -339,8 +351,22 @@ class CoreIAM {
339
351
  const existingCookieStr = incomingHeaders.get('cookie') || '';
340
352
  const newCookiePairs = csrfCookies.map(c => c.split(';')[0]);
341
353
  if (newCookiePairs.length > 0) {
342
- const combinedCookies = [existingCookieStr, ...newCookiePairs].filter(Boolean).join('; ');
343
- authHeaders.set('cookie', combinedCookies);
354
+ // FIX (SDK-4): dedupe by cookie NAME, newest value wins. The old
355
+ // [existing, ...new].join() emitted 'csrfToken=old; csrfToken=new' once
356
+ // the browser jar already held a csrf cookie and the handshake rotated
357
+ // it — whether the backend parser reads the first or last duplicate
358
+ // decided 403-or-pass. Nondeterministic across parsers is a bug.
359
+ const jar = new Map();
360
+ for (const pair of [...existingCookieStr.split(';'), ...newCookiePairs]) {
361
+ const trimmed = pair.trim();
362
+ if (!trimmed)
363
+ continue;
364
+ const eq = trimmed.indexOf('=');
365
+ if (eq === -1)
366
+ continue;
367
+ jar.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
368
+ }
369
+ authHeaders.set('cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '));
344
370
  }
345
371
  return this.proxy(path, init, authHeaders);
346
372
  }
@@ -399,10 +425,77 @@ class CoreIAM {
399
425
  body: JSON.stringify(body),
400
426
  }, incomingHeaders);
401
427
  }
428
+ // W3 beta.6: forgot-password / reset-password. Both go through the same
429
+ // CSRF funnel as every other CsrfGuard-decorated endpoint — the paths are
430
+ // pre-registered in CSRF_PROTECTED_PATHS (SDK-6), so the middleware proxy
431
+ // and these methods stay in parity.
432
+ async forgotPassword(email, incomingHeaders) {
433
+ return this.proxyWithCsrfProtection('/auth/request-reset', {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify({ email }),
437
+ }, incomingHeaders);
438
+ }
439
+ async verifyEmail(body, incomingHeaders) {
440
+ return this.proxyWithCsrfProtection('/auth/verify-email', {
441
+ method: 'POST',
442
+ headers: { 'Content-Type': 'application/json' },
443
+ body: JSON.stringify(body),
444
+ }, incomingHeaders);
445
+ }
446
+ async resendVerification(email, incomingHeaders) {
447
+ return this.proxyWithCsrfProtection('/auth/resend-verification', {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ email }),
451
+ }, incomingHeaders);
452
+ }
453
+ async resetPassword(email, otp, newPassword, incomingHeaders) {
454
+ return this.proxyWithCsrfProtection('/auth/reset-password', {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json' },
457
+ body: JSON.stringify({ email, otp, newPassword }),
458
+ }, incomingHeaders);
459
+ }
460
+ // W4 (beta.7): session management surface. listSessions is a plain
461
+ // authenticated GET (same shape as the /auth/profile GET precedent —
462
+ // JWT comes from the forwarded cookie via proxy(), reads carry no CSRF
463
+ // requirement); the two state-changing calls go through the shared CSRF
464
+ // handshake exactly like changePassword.
465
+ async listSessions(incomingHeaders) {
466
+ return this.proxy('/auth/sessions', { method: 'GET' }, incomingHeaders);
467
+ }
468
+ async revokeSession(sessionId, incomingHeaders) {
469
+ // Same rationale as OAUTH_PROVIDERS (SDK-5): never interpolate an
470
+ // unvalidated path segment into a URL. The backend's ParseUUIDPipe
471
+ // would reject garbage with a 400 anyway — this fails fast
472
+ // client-side and can never become a traversal string.
473
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
474
+ throw new Error('revokeSession: sessionId must be a UUID');
475
+ }
476
+ return this.proxyWithCsrfProtection(`/auth/sessions/${sessionId}`, { method: 'DELETE' }, incomingHeaders);
477
+ }
478
+ async revokeAllSessions(incomingHeaders) {
479
+ return this.proxyWithCsrfProtection('/auth/sessions/revoke-all', { method: 'POST' }, incomingHeaders);
480
+ }
402
481
  }
403
482
  exports.CoreIAM = CoreIAM;
404
483
  exports.CSRF_PROTECTED_PATHS = new Set([
405
484
  '/auth/login', '/auth/register', '/auth/discover-tenants',
406
- '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login',
485
+ '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login', '/auth/verify-email', '/auth/resend-verification',
407
486
  '/auth/profile', '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
487
+ '/auth/password',
488
+ '/auth/request-reset', '/auth/reset-password',
489
+ // W4 (beta.7): revoke-all is exact-matched. The per-session revoke is a
490
+ // DYNAMIC path (/auth/sessions/<uuid>) a string Set cannot match — the SDK
491
+ // methods self-handshake via proxyWithCsrfProtection so server-action
492
+ // callers are fully covered; middleware-proxied browser-direct DELETEs to
493
+ // /api/auth/sessions/<uuid> are not auto-handshaked (known limitation; the
494
+ // fix is a prefix matcher inside middleware.ts, deliberately deferred to
495
+ // keep this patch off that file).
496
+ '/auth/sessions/revoke-all',
408
497
  ]);
498
+ // FIX (SDK-5): single source of truth for supported OAuth providers. Every
499
+ // place that interpolates a provider segment into a backend URL must check
500
+ // this set first — an unvalidated path segment is a traversal surface.
501
+ exports.OAUTH_PROVIDERS = new Set(['google', 'github']);
@@ -1,7 +1,8 @@
1
- import { CoreIAMIdentity } from '../core';
1
+ import { CoreIAMIdentity, RequestOtpBody, VerifyOtpBody, VerifyEmailBody } from '../core';
2
2
  export interface AuthResult {
3
3
  ok: boolean;
4
4
  status: number;
5
+ retryAfter?: number;
5
6
  data: any;
6
7
  }
7
8
  export declare function getSession(): Promise<CoreIAMIdentity | null>;
@@ -13,5 +14,12 @@ export declare function changePasswordActionSdk(currentPassword: string, newPass
13
14
  export declare function setupMfaActionSdk(): Promise<AuthResult>;
14
15
  export declare function verifyMfaActionSdk(token: string): Promise<AuthResult>;
15
16
  export declare function disableMfaActionSdk(): Promise<AuthResult>;
16
- export declare function requestOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
17
- export declare function verifyOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
17
+ export declare function requestOtpActionSdk(body: RequestOtpBody): Promise<AuthResult>;
18
+ export declare function verifyOtpActionSdk(body: VerifyOtpBody): Promise<AuthResult>;
19
+ export declare function verifyEmailActionSdk(body: VerifyEmailBody): Promise<AuthResult>;
20
+ export declare function resendVerificationActionSdk(email: string): Promise<AuthResult>;
21
+ export declare function forgotPasswordActionSdk(email: string): Promise<AuthResult>;
22
+ export declare function resetPasswordActionSdk(email: string, otp: string, newPassword: string): Promise<AuthResult>;
23
+ export declare function listSessionsActionSdk(): Promise<AuthResult>;
24
+ export declare function revokeSessionActionSdk(sessionId: string): Promise<AuthResult>;
25
+ export declare function revokeAllSessionsActionSdk(): Promise<AuthResult>;
@@ -105,6 +105,13 @@ exports.verifyMfaActionSdk = verifyMfaActionSdk;
105
105
  exports.disableMfaActionSdk = disableMfaActionSdk;
106
106
  exports.requestOtpActionSdk = requestOtpActionSdk;
107
107
  exports.verifyOtpActionSdk = verifyOtpActionSdk;
108
+ exports.verifyEmailActionSdk = verifyEmailActionSdk;
109
+ exports.resendVerificationActionSdk = resendVerificationActionSdk;
110
+ exports.forgotPasswordActionSdk = forgotPasswordActionSdk;
111
+ exports.resetPasswordActionSdk = resetPasswordActionSdk;
112
+ exports.listSessionsActionSdk = listSessionsActionSdk;
113
+ exports.revokeSessionActionSdk = revokeSessionActionSdk;
114
+ exports.revokeAllSessionsActionSdk = revokeAllSessionsActionSdk;
108
115
  const headers_1 = require("next/headers");
109
116
  const core_1 = require("../core");
110
117
  // FIX: lazy singleton instead of `const iam = new CoreIAM();` at module
@@ -211,34 +218,13 @@ async function getSession() {
211
218
  return null;
212
219
  }
213
220
  }
214
- // Fallback: If middleware didn't run (e.g. API route), we can still read the JWT
215
- // But we DO NOT verify it here, because verification requires the JWKS fetch which
216
- // is too slow for Server Components. We just decode it to get the identity.
217
- // The middleware is the true security gate.
218
- const authHeader = reqHeaders.get('Authorization') || reqHeaders.get('authorization');
219
- const cookieHeader = reqHeaders.get('cookie');
220
- let token = undefined;
221
- if (authHeader?.startsWith('Bearer ')) {
222
- token = authHeader.substring(7);
223
- }
224
- else if (cookieHeader) {
225
- const match = cookieHeader.match(/jwt=([^;]+)/);
226
- if (match)
227
- token = match[1];
228
- }
229
- if (!token)
230
- return null;
221
+ // FIX (SDK-1): delegate to the SAME verification funnel coreIamMiddleware
222
+ // uses (CoreIAM.verifySession — jose, RS256-pinned, issuer-checked) instead
223
+ // of decoding the payload unverified. One implementation, zero drift,
224
+ // no new env vars. Forged tokens -> null.
231
225
  try {
232
- // Just decode the payload without verifying (middleware already verified it)
233
- const payloadB64 = token.split('.')[1];
234
- const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
235
- const payload = JSON.parse(payloadStr);
236
- return {
237
- sub: payload.sub,
238
- email: payload.email,
239
- tenantId: payload.tenantId,
240
- roles: payload.roles || []
241
- };
226
+ const result = await getIam().verifySession(reqHeaders);
227
+ return result.identity ?? null;
242
228
  }
243
229
  catch {
244
230
  return null;
@@ -336,7 +322,12 @@ async function requestOtpActionSdk(body) {
336
322
  const reqHeaders = await (0, headers_1.headers)();
337
323
  try {
338
324
  const response = await getIam().requestOtp(body, reqHeaders);
339
- return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
325
+ // FIX (SDK-3, uniformity): request-otp currently sets no cookies, but routing
326
+ // any Set-Cookie through the same sync as every other auth action keeps the
327
+ // invariant above true if the backend ever starts setting one.
328
+ await syncCookiesToNext(response);
329
+ const data = await response.json().catch(() => ({}));
330
+ return { ok: response.ok, status: response.status, data, retryAfter: (0, core_1.retryAfterSeconds)(data) };
340
331
  }
341
332
  catch (error) {
342
333
  return { ok: false, status: 500, data: { error: 'OTP request failed' } };
@@ -346,9 +337,108 @@ async function verifyOtpActionSdk(body) {
346
337
  const reqHeaders = await (0, headers_1.headers)();
347
338
  try {
348
339
  const response = await getIam().verifyOtp(body, reqHeaders);
349
- return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
340
+ // FIX (SDK-3): sync Set-Cookie to the Next.js cookie store before returning.
341
+ // W2's POST /auth/verify-otp mints an OTP session and Set-Cookies the jwt
342
+ // cookie (Max-Age 900 — verified live in acceptance). Without this line the
343
+ // action silently dropped the session cookie, so passwordless login via
344
+ // server action never established a browser session. Mirrors the
345
+ // loginActionSdk / registerActionSdk pattern exactly.
346
+ await syncCookiesToNext(response);
347
+ const data = await response.json().catch(() => ({}));
348
+ return { ok: response.ok, status: response.status, data, retryAfter: (0, core_1.retryAfterSeconds)(data) };
350
349
  }
351
350
  catch (error) {
352
351
  return { ok: false, status: 500, data: { error: 'OTP verification failed' } };
353
352
  }
354
353
  }
354
+ // W5 (beta.9): signup email verification — register fires the verification
355
+ // email (emailVerificationRequired: true); these close the loop so the SDK
356
+ // surface matches the server's /auth/verify-email + resend-verification pair.
357
+ async function verifyEmailActionSdk(body) {
358
+ const reqHeaders = await (0, headers_1.headers)();
359
+ try {
360
+ const response = await getIam().verifyEmail(body, reqHeaders);
361
+ await syncCookiesToNext(response);
362
+ const data = await response.json().catch(() => ({}));
363
+ return { ok: response.ok, status: response.status, data, retryAfter: (0, core_1.retryAfterSeconds)(data) };
364
+ }
365
+ catch (error) {
366
+ return { ok: false, status: 500, data: { error: 'Email verification failed' } };
367
+ }
368
+ }
369
+ async function resendVerificationActionSdk(email) {
370
+ const reqHeaders = await (0, headers_1.headers)();
371
+ try {
372
+ const response = await getIam().resendVerification(email, reqHeaders);
373
+ await syncCookiesToNext(response);
374
+ const data = await response.json().catch(() => ({}));
375
+ return { ok: response.ok, status: response.status, data, retryAfter: (0, core_1.retryAfterSeconds)(data) };
376
+ }
377
+ catch (error) {
378
+ return { ok: false, status: 500, data: { error: 'Verification resend failed' } };
379
+ }
380
+ }
381
+ async function forgotPasswordActionSdk(email) {
382
+ const reqHeaders = await (0, headers_1.headers)();
383
+ try {
384
+ const response = await getIam().forgotPassword(email, 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: 'Password reset request failed' } };
390
+ }
391
+ }
392
+ async function resetPasswordActionSdk(email, otp, newPassword) {
393
+ const reqHeaders = await (0, headers_1.headers)();
394
+ try {
395
+ const response = await getIam().resetPassword(email, otp, newPassword, reqHeaders);
396
+ // W2's reset-password revokes ALL session families server-side
397
+ // (revokeAllUserSessions) and clears cookies via Set-Cookie deletions —
398
+ // this sync applies them to the browser. Without it the client would sit
399
+ // on dead cookies after a successful reset.
400
+ await syncCookiesToNext(response);
401
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
402
+ }
403
+ catch (error) {
404
+ return { ok: false, status: 500, data: { error: 'Password reset failed' } };
405
+ }
406
+ }
407
+ // --- W4 (beta.7): session management. listSessions in a Server Component,
408
+ // revoke calls from client components via server actions — same funnel and
409
+ // same AuthResult contract as every action above. The backend sets no
410
+ // cookies on these routes, but Set-Cookie sync stays wired for uniformity
411
+ // (SDK-3 pattern).
412
+ async function listSessionsActionSdk() {
413
+ const reqHeaders = await (0, headers_1.headers)();
414
+ try {
415
+ const response = await getIam().listSessions(reqHeaders);
416
+ await syncCookiesToNext(response);
417
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
418
+ }
419
+ catch (error) {
420
+ return { ok: false, status: 500, data: { error: 'Failed to list sessions' } };
421
+ }
422
+ }
423
+ async function revokeSessionActionSdk(sessionId) {
424
+ const reqHeaders = await (0, headers_1.headers)();
425
+ try {
426
+ const response = await getIam().revokeSession(sessionId, reqHeaders);
427
+ await syncCookiesToNext(response);
428
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
429
+ }
430
+ catch (error) {
431
+ return { ok: false, status: 500, data: { error: error instanceof Error ? error.message : 'Session revoke failed' } };
432
+ }
433
+ }
434
+ async function revokeAllSessionsActionSdk() {
435
+ const reqHeaders = await (0, headers_1.headers)();
436
+ try {
437
+ const response = await getIam().revokeAllSessions(reqHeaders);
438
+ await syncCookiesToNext(response);
439
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
440
+ }
441
+ catch (error) {
442
+ return { ok: false, status: 500, data: { error: 'Revoke-all failed' } };
443
+ }
444
+ }
@@ -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;
@@ -51,7 +51,7 @@
51
51
  // export function useAuth() {
52
52
  // return useContext(AuthContext);
53
53
  // }
54
- // src/nextjs/provider.tsx
54
+ // sdk/javascript/src/nextjs/provider.tsx
55
55
  'use client';
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.CoreIAMProvider = CoreIAMProvider;
@@ -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);
@@ -44,6 +44,7 @@ export function useAuth() {
44
44
  }
45
45
 
46
46
  */
47
+ //sdk/javascript/src/react/provider.tsx
47
48
  'use client';
48
49
  Object.defineProperty(exports, "__esModule", { value: true });
49
50
  exports.CoreIAMProvider = CoreIAMProvider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreiam/auth",
3
- "version": "1.0.392-beta.5",
3
+ "version": "1.0.392-beta.9",
4
4
  "main": "./dist/core/index.js",
5
5
  "types": "./dist/core/index.d.ts",
6
6
  "exports": {
@@ -56,7 +56,8 @@
56
56
  "author": "coreiam",
57
57
  "license": "MIT",
58
58
  "publishConfig": {
59
- "access": "public"
59
+ "access": "public",
60
+ "tag": "beta"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "express": "^4.0.0 || ^5.0.0",