@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.
- package/dist/core/index.d.ts +45 -2
- package/dist/core/index.js +97 -4
- package/dist/nextjs/actions.d.ts +11 -3
- package/dist/nextjs/actions.js +119 -29
- package/dist/nextjs/index.d.ts +1 -1
- package/dist/nextjs/index.js +55 -11
- package/dist/nextjs/middleware.js +114 -27
- package/dist/nextjs/provider.d.ts +17 -1
- package/dist/nextjs/provider.js +136 -4
- package/dist/react/provider.js +1 -0
- package/package.json +3 -2
package/dist/core/index.d.ts
CHANGED
|
@@ -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:
|
|
144
|
-
verifyOtp(body:
|
|
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
|
+
}
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
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']);
|
package/dist/nextjs/actions.d.ts
CHANGED
|
@@ -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:
|
|
17
|
-
export declare function verifyOtpActionSdk(body:
|
|
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>;
|
package/dist/nextjs/actions.js
CHANGED
|
@@ -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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -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);
|
package/dist/nextjs/index.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
104
|
-
if (cached
|
|
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
|
-
|
|
171
|
+
cacheSet(token, { valid: true, identity, expires: Date.now() + CACHE_TTL_MS });
|
|
128
172
|
return identity;
|
|
129
173
|
}
|
|
130
174
|
else {
|
|
131
|
-
|
|
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
|
-
|
|
163
|
+
const maxAgeMatch = cookie.match(/max-age=(-?\d+)/i);
|
|
164
|
+
if (cookie.startsWith('jwt=')) {
|
|
160
165
|
newJwt = cookie.split(';')[0].split('=')[1];
|
|
161
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/nextjs/provider.js
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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/dist/react/provider.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coreiam/auth",
|
|
3
|
-
"version": "1.0.392-beta.
|
|
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",
|