@coreiam/auth 1.0.392-beta.1 → 1.0.392-beta.12

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.
@@ -99,11 +99,19 @@ 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.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;
107
115
  const headers_1 = require("next/headers");
108
116
  const core_1 = require("../core");
109
117
  // FIX: lazy singleton instead of `const iam = new CoreIAM();` at module
@@ -210,34 +218,13 @@ async function getSession() {
210
218
  return null;
211
219
  }
212
220
  }
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;
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.
230
225
  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
- };
226
+ const result = await getIam().verifySession(reqHeaders);
227
+ return result.identity ?? null;
241
228
  }
242
229
  catch {
243
230
  return null;
@@ -291,6 +278,16 @@ async function updateProfileActionSdk(updateData) {
291
278
  return { ok: false, status: 500, data: { error: 'Profile update failed' } };
292
279
  }
293
280
  }
281
+ async function changePasswordActionSdk(currentPassword, newPassword) {
282
+ const reqHeaders = await (0, headers_1.headers)();
283
+ try {
284
+ const response = await getIam().changePassword(currentPassword, newPassword, reqHeaders);
285
+ return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
286
+ }
287
+ catch (error) {
288
+ return { ok: false, status: 500, data: { error: 'Password change failed' } };
289
+ }
290
+ }
294
291
  async function setupMfaActionSdk() {
295
292
  const reqHeaders = await (0, headers_1.headers)();
296
293
  try {
@@ -325,7 +322,12 @@ async function requestOtpActionSdk(body) {
325
322
  const reqHeaders = await (0, headers_1.headers)();
326
323
  try {
327
324
  const response = await getIam().requestOtp(body, reqHeaders);
328
- 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) };
329
331
  }
330
332
  catch (error) {
331
333
  return { ok: false, status: 500, data: { error: 'OTP request failed' } };
@@ -335,9 +337,108 @@ async function verifyOtpActionSdk(body) {
335
337
  const reqHeaders = await (0, headers_1.headers)();
336
338
  try {
337
339
  const response = await getIam().verifyOtp(body, reqHeaders);
338
- 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) };
339
349
  }
340
350
  catch (error) {
341
351
  return { ok: false, status: 500, data: { error: 'OTP verification failed' } };
342
352
  }
343
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,11 +122,11 @@ 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");
129
- const gatewayUrl = process.env.COREIAM_API_URL;
129
+ const gatewayUrl = (0, core_1.resolveGatewayUrl)({ apiKey: process.env.COREIAM_API_KEY || '' });
130
130
  /**
131
131
  const JWKS = gatewayUrl
132
132
  ? createRemoteJWKSet(new URL(`${gatewayUrl}/.well-known/jwks.json`))
@@ -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');
@@ -194,7 +235,7 @@ async function coreIamAuth(request) {
194
235
  const iam = getIam();
195
236
  if (!iam) {
196
237
  return {
197
- response: new server_1.NextResponse('CoreIAM is not configured (COREIAM_API_URL / COREIAM_API_KEY)', { status: 500 }),
238
+ response: new server_1.NextResponse('CoreIAM is not configured (COREIAM_API_KEY required; gateway URL defaults by key prefix)', { status: 500 }),
198
239
  isAuthenticated: false,
199
240
  };
200
241
  }
@@ -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,25 +304,46 @@ 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');
277
331
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
278
- targetPath = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${redirectUri}`;
332
+ // targetPath = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${redirectUri}`;
333
+ // OAuth callback targetPath — encode all three:
334
+ targetPath = `/auth/oauth/callback/${provider}`
335
+ + `?code=${encodeURIComponent(code ?? '')}`
336
+ + `&state=${encodeURIComponent(state ?? '')}`
337
+ + `&redirectUri=${encodeURIComponent(redirectUri)}`;
279
338
  }
280
- const response = await iam.proxy(targetPath, {
339
+ const init = {
281
340
  method,
282
341
  body: body ? JSON.stringify(body) : undefined,
283
342
  headers: { 'Content-Type': 'application/json' },
284
- }, request.headers);
343
+ };
344
+ const response = core_1.CSRF_PROTECTED_PATHS.has(targetPath)
345
+ ? await iam.proxyWithCsrfProtection(targetPath, init, request.headers)
346
+ : await iam.proxy(targetPath, init, request.headers);
285
347
  // Handle OAuth Initiate Redirects
286
348
  if (pathname.startsWith('/api/auth/oauth/') && !pathname.includes('callback') && !pathname.includes('link')) {
287
349
  const data = await response.json().catch(() => ({}));
@@ -349,6 +411,13 @@ async function coreIamAuth(request) {
349
411
 
350
412
  */
351
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');
352
421
  // Now delegates to CoreIAM.verifySession — the same code path Express
353
422
  // users get, so the two implementations can never drift.
354
423
  const jwtCookie = request.cookies.get("jwt");
@@ -358,7 +427,7 @@ async function coreIamAuth(request) {
358
427
  if (client) {
359
428
  const result = await client.verifySession(request.headers);
360
429
  if (result.identity) {
361
- const requestHeaders = new Headers(request.headers);
430
+ const requestHeaders = new Headers(sanitizedHeaders);
362
431
  requestHeaders.set('x-coreiam-identity', JSON.stringify(result.identity));
363
432
  return {
364
433
  response: server_1.NextResponse.next({ request: { headers: requestHeaders } }),
@@ -380,33 +449,56 @@ async function coreIamAuth(request) {
380
449
  }
381
450
  const result = await refreshInFlight;
382
451
  if (result) {
383
- const requestHeaders = new Headers(request.headers);
452
+ const requestHeaders = new Headers(sanitizedHeaders);
384
453
  requestHeaders.set('cookie', `jwt=${result.jwt}; refreshToken=${result.refreshToken}`);
385
454
  requestHeaders.set('Authorization', `Bearer ${result.jwt}`);
386
- try {
387
- const payloadB64 = result.jwt.split('.')[1];
388
- const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
389
- const payload = JSON.parse(payloadStr);
390
- requestHeaders.set('x-coreiam-identity', JSON.stringify({
391
- sub: payload.sub,
392
- email: payload.email,
393
- tenantId: payload.tenantId,
394
- roles: payload.roles || []
395
- }));
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));
396
482
  }
397
- catch { }
483
+ // FIX: forward requestHeaders (fresh cookie + identity) — NOT sanitizedHeaders,
484
+ // which still carries the old expired cookie.
398
485
  const response = server_1.NextResponse.next({ request: { headers: requestHeaders } });
399
486
  const isProd = process.env.NODE_ENV === 'production';
400
- response.cookies.set('jwt', result.jwt, { httpOnly: true, secure: isProd, sameSite: 'strict', maxAge: 24 * 60 * 60 });
401
- 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 });
402
492
  return { response, isAuthenticated: true };
403
493
  }
404
- 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 } });
405
497
  response.cookies.delete('jwt');
406
498
  response.cookies.delete('refreshToken');
407
499
  return { response, isAuthenticated: false };
408
500
  }
409
- return { response: server_1.NextResponse.next(), isAuthenticated: false };
501
+ return { response: server_1.NextResponse.next({ request: { headers: sanitizedHeaders } }), isAuthenticated: false };
410
502
  }
411
503
  /**
412
504
  * 1-line middleware factory for developers who don't need custom routing.
@@ -416,7 +508,10 @@ function coreIamMiddleware(options = {}) {
416
508
  return async (request) => {
417
509
  const { response, isAuthenticated } = await coreIamAuth(request);
418
510
  const pathname = request.nextUrl.pathname;
419
- 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 + '/'));
420
515
  // If not authenticated and trying to access a protected route
421
516
  if (!isAuthenticated && !isPublicRoute) {
422
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;