@sylphx/sdk 0.10.3 → 0.10.5
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/README.md +6 -0
- package/dist/health/index.d.ts +681 -0
- package/dist/index.d.ts +56 -29
- package/dist/index.mjs +102 -26
- package/dist/index.mjs.map +1 -1
- package/dist/nextjs/index.d.ts +65 -1
- package/dist/nextjs/index.mjs +386 -27
- package/dist/nextjs/index.mjs.map +1 -1
- package/dist/react/index.d.ts +6 -1
- package/dist/react/index.mjs +24 -4
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.mjs.map +1 -1
- package/dist/web-analytics.mjs.map +1 -1
- package/package.json +21 -2
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -63,6 +63,9 @@ interface SylphxMiddlewareConfig {
|
|
|
63
63
|
afterSignInUrl?: string;
|
|
64
64
|
/**
|
|
65
65
|
* Auth routes prefix. Routes are mounted at:
|
|
66
|
+
* - {prefix}/login — credentials login handler
|
|
67
|
+
* - {prefix}/oauth-providers — enabled social login providers
|
|
68
|
+
* - {prefix}/oauth/authorize — social login start handler
|
|
66
69
|
* - {prefix}/callback — OAuth callback handler
|
|
67
70
|
* - {prefix}/signout — Sign out handler
|
|
68
71
|
*
|
|
@@ -126,6 +129,42 @@ interface SylphxMiddlewareConfig {
|
|
|
126
129
|
* @default '/select-organization'
|
|
127
130
|
*/
|
|
128
131
|
orgSelectionUrl?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Active organization context behaviour.
|
|
134
|
+
*
|
|
135
|
+
* Enabled by default so Next.js apps get durable org-scoped sessions without
|
|
136
|
+
* writing their own refresh/switch orchestration:
|
|
137
|
+
* - `/auth/switch-org` records the selected org in SDK-owned HttpOnly cookies
|
|
138
|
+
* - token refresh restores that org-scoped JWT when possible
|
|
139
|
+
* - single-org users are auto-scoped after refresh
|
|
140
|
+
*
|
|
141
|
+
* Apps migrating from pre-SDK org cookies can list additional cookie names
|
|
142
|
+
* for read compatibility. The SDK still writes only its own names.
|
|
143
|
+
*/
|
|
144
|
+
organizationContext?: SylphxOrganizationContextConfig;
|
|
145
|
+
}
|
|
146
|
+
interface SylphxOrganizationContextConfig {
|
|
147
|
+
/**
|
|
148
|
+
* Preserve active organization context through refresh.
|
|
149
|
+
* @default true
|
|
150
|
+
*/
|
|
151
|
+
enabled?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* When no active org preference exists and the user has exactly one org,
|
|
154
|
+
* automatically restore an org-scoped session token after refresh.
|
|
155
|
+
* @default true
|
|
156
|
+
*/
|
|
157
|
+
autoSelectSingleOrganization?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Additional org-id cookie names to read during migration from existing apps.
|
|
160
|
+
* The SDK-owned `__{namespace}_active_org_id` cookie is always read first.
|
|
161
|
+
*/
|
|
162
|
+
additionalOrgIdCookies?: readonly string[];
|
|
163
|
+
/**
|
|
164
|
+
* Additional org-slug cookie names to read during migration from existing apps.
|
|
165
|
+
* The SDK-owned `__{namespace}_active_org_slug` cookie is always read first.
|
|
166
|
+
*/
|
|
167
|
+
additionalOrgSlugCookies?: readonly string[];
|
|
129
168
|
}
|
|
130
169
|
/**
|
|
131
170
|
* Create Sylphx middleware — State of the Art
|
|
@@ -485,6 +524,10 @@ declare function getCookieNames(namespace: string): {
|
|
|
485
524
|
REFRESH: string;
|
|
486
525
|
/** JS-readable user data for client hydration (5 min) */
|
|
487
526
|
USER: string;
|
|
527
|
+
/** HttpOnly active organization ID used to preserve org-scoped sessions */
|
|
528
|
+
ACTIVE_ORG_ID: string;
|
|
529
|
+
/** HttpOnly active organization slug used to preserve org-scoped sessions */
|
|
530
|
+
ACTIVE_ORG_SLUG: string;
|
|
488
531
|
};
|
|
489
532
|
/**
|
|
490
533
|
* Session token lifetime (5 minutes like Clerk)
|
|
@@ -494,6 +537,13 @@ declare const SESSION_TOKEN_LIFETIME: number;
|
|
|
494
537
|
* Refresh token lifetime (30 days)
|
|
495
538
|
*/
|
|
496
539
|
declare const REFRESH_TOKEN_LIFETIME: number;
|
|
540
|
+
/**
|
|
541
|
+
* Active organization context lifetime (30 days).
|
|
542
|
+
*
|
|
543
|
+
* This matches refresh-token lifetime: the org preference is session-scoped
|
|
544
|
+
* state, not a permanent user preference. Clearing auth clears this too.
|
|
545
|
+
*/
|
|
546
|
+
declare const ACTIVE_ORG_LIFETIME: number;
|
|
497
547
|
/**
|
|
498
548
|
* Cookie options for HttpOnly tokens (session, refresh)
|
|
499
549
|
*
|
|
@@ -521,6 +571,20 @@ declare const USER_COOKIE_OPTIONS: {
|
|
|
521
571
|
sameSite: "lax";
|
|
522
572
|
path: string;
|
|
523
573
|
};
|
|
574
|
+
/**
|
|
575
|
+
* Cookie options for active organization context.
|
|
576
|
+
*
|
|
577
|
+
* Active org is not a secret, but it controls which org-scoped JWT the SDK
|
|
578
|
+
* restores after refresh. Keep it HttpOnly so browser JavaScript cannot
|
|
579
|
+
* silently steer server-side auth context outside the official switch-org
|
|
580
|
+
* route.
|
|
581
|
+
*/
|
|
582
|
+
declare const ACTIVE_ORG_COOKIE_OPTIONS: {
|
|
583
|
+
httpOnly: boolean;
|
|
584
|
+
secure: boolean;
|
|
585
|
+
sameSite: "lax";
|
|
586
|
+
path: string;
|
|
587
|
+
};
|
|
524
588
|
/**
|
|
525
589
|
* Auth cookies data returned by getAuthCookies
|
|
526
590
|
*/
|
|
@@ -588,4 +652,4 @@ declare function clearAuthCookiesMiddleware(response: NextResponse, namespace: s
|
|
|
588
652
|
*/
|
|
589
653
|
declare function parseUserCookie(value: string): UserCookieData | null;
|
|
590
654
|
|
|
591
|
-
export { type AuthCookiesData, type AuthResult, REFRESH_TOKEN_LIFETIME, SECURE_COOKIE_OPTIONS, SESSION_TOKEN_LIFETIME, SESSION_TOKEN_LIFETIME_MS, type SylphxMiddlewareConfig, TOKEN_EXPIRY_BUFFER_MS, USER_COOKIE_OPTIONS, type UserCookieData, auth, clearAuthCookies, clearAuthCookiesMiddleware, configureServer, createMatcher, createSylphxMiddleware, currentUser, currentUserId, decodeUserId, encodeUserId, getAuthCookies, getAuthorizationUrl, getCookieNames, getNamespace, getSessionToken, handleCallback, hasRefreshToken, isSessionExpired, parseUserCookie, setAuthCookies, setAuthCookiesMiddleware, signOut, sylphxMiddleware, syncAuthToCookies };
|
|
655
|
+
export { ACTIVE_ORG_COOKIE_OPTIONS, ACTIVE_ORG_LIFETIME, type AuthCookiesData, type AuthResult, REFRESH_TOKEN_LIFETIME, SECURE_COOKIE_OPTIONS, SESSION_TOKEN_LIFETIME, SESSION_TOKEN_LIFETIME_MS, type SylphxMiddlewareConfig, type SylphxOrganizationContextConfig, TOKEN_EXPIRY_BUFFER_MS, USER_COOKIE_OPTIONS, type UserCookieData, auth, clearAuthCookies, clearAuthCookiesMiddleware, configureServer, createMatcher, createSylphxMiddleware, currentUser, currentUserId, decodeUserId, encodeUserId, getAuthCookies, getAuthorizationUrl, getCookieNames, getNamespace, getSessionToken, handleCallback, hasRefreshToken, isSessionExpired, parseUserCookie, setAuthCookies, setAuthCookiesMiddleware, signOut, sylphxMiddleware, syncAuthToCookies };
|
package/dist/nextjs/index.mjs
CHANGED
|
@@ -189,11 +189,16 @@ function getCookieNames(namespace) {
|
|
|
189
189
|
/** HttpOnly refresh token (30 days) */
|
|
190
190
|
REFRESH: `__${namespace}_refresh`,
|
|
191
191
|
/** JS-readable user data for client hydration (5 min) */
|
|
192
|
-
USER: `__${namespace}_user
|
|
192
|
+
USER: `__${namespace}_user`,
|
|
193
|
+
/** HttpOnly active organization ID used to preserve org-scoped sessions */
|
|
194
|
+
ACTIVE_ORG_ID: `__${namespace}_active_org_id`,
|
|
195
|
+
/** HttpOnly active organization slug used to preserve org-scoped sessions */
|
|
196
|
+
ACTIVE_ORG_SLUG: `__${namespace}_active_org_slug`
|
|
193
197
|
};
|
|
194
198
|
}
|
|
195
199
|
var SESSION_TOKEN_LIFETIME = SESSION_TOKEN_LIFETIME_SECONDS;
|
|
196
200
|
var REFRESH_TOKEN_LIFETIME = REFRESH_TOKEN_LIFETIME_SECONDS;
|
|
201
|
+
var ACTIVE_ORG_LIFETIME = REFRESH_TOKEN_LIFETIME_SECONDS;
|
|
197
202
|
var SECURE_COOKIE_OPTIONS = {
|
|
198
203
|
httpOnly: true,
|
|
199
204
|
secure: process.env.NODE_ENV === "production",
|
|
@@ -207,6 +212,10 @@ var USER_COOKIE_OPTIONS = {
|
|
|
207
212
|
sameSite: "lax",
|
|
208
213
|
path: "/"
|
|
209
214
|
};
|
|
215
|
+
var ACTIVE_ORG_COOKIE_OPTIONS = {
|
|
216
|
+
...SECURE_COOKIE_OPTIONS,
|
|
217
|
+
httpOnly: true
|
|
218
|
+
};
|
|
210
219
|
async function getAuthCookies(namespace) {
|
|
211
220
|
const cookieStore = await cookies();
|
|
212
221
|
const names = getCookieNames(namespace);
|
|
@@ -255,6 +264,8 @@ async function clearAuthCookies(namespace) {
|
|
|
255
264
|
cookieStore.delete(names.SESSION);
|
|
256
265
|
cookieStore.delete(names.REFRESH);
|
|
257
266
|
cookieStore.delete(names.USER);
|
|
267
|
+
cookieStore.delete(names.ACTIVE_ORG_ID);
|
|
268
|
+
cookieStore.delete(names.ACTIVE_ORG_SLUG);
|
|
258
269
|
}
|
|
259
270
|
async function isSessionExpired(namespace) {
|
|
260
271
|
const { expiresAt } = await getAuthCookies(namespace);
|
|
@@ -290,6 +301,8 @@ function clearAuthCookiesMiddleware(response, namespace) {
|
|
|
290
301
|
response.cookies.delete(names.SESSION);
|
|
291
302
|
response.cookies.delete(names.REFRESH);
|
|
292
303
|
response.cookies.delete(names.USER);
|
|
304
|
+
response.cookies.delete(names.ACTIVE_ORG_ID);
|
|
305
|
+
response.cookies.delete(names.ACTIVE_ORG_SLUG);
|
|
293
306
|
}
|
|
294
307
|
function parseUserCookie(value) {
|
|
295
308
|
try {
|
|
@@ -440,6 +453,8 @@ function resolveNextjsSecretKey(options) {
|
|
|
440
453
|
}
|
|
441
454
|
|
|
442
455
|
// src/nextjs/middleware.ts
|
|
456
|
+
var OAUTH_PKCE_TTL_SECONDS = 10 * 60;
|
|
457
|
+
var OAUTH_PKCE_TTL_MS = OAUTH_PKCE_TTL_SECONDS * 1e3;
|
|
443
458
|
function isTokenResponse(data) {
|
|
444
459
|
return typeof data === "object" && data !== null && "accessToken" in data && "refreshToken" in data && "user" in data && typeof data.accessToken === "string" && typeof data.refreshToken === "string";
|
|
445
460
|
}
|
|
@@ -476,6 +491,163 @@ function matchesPattern(pathname, pattern) {
|
|
|
476
491
|
function matchesAny(pathname, patterns) {
|
|
477
492
|
return patterns.some((p) => matchesPattern(pathname, p));
|
|
478
493
|
}
|
|
494
|
+
function requestPathWithSearch(request) {
|
|
495
|
+
const { pathname, search } = request.nextUrl;
|
|
496
|
+
return `${pathname}${search}`;
|
|
497
|
+
}
|
|
498
|
+
function isSafeRelativeRedirectPath(value) {
|
|
499
|
+
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//");
|
|
500
|
+
}
|
|
501
|
+
function resolveSafeRelativeRedirectPath(value, fallback) {
|
|
502
|
+
if (isSafeRelativeRedirectPath(value)) return value;
|
|
503
|
+
if (isSafeRelativeRedirectPath(fallback)) return fallback;
|
|
504
|
+
return "/";
|
|
505
|
+
}
|
|
506
|
+
function bytesToBase64Url(bytes) {
|
|
507
|
+
let binary = "";
|
|
508
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
509
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
|
|
510
|
+
}
|
|
511
|
+
function randomBase64Url(byteLength) {
|
|
512
|
+
const bytes = new Uint8Array(byteLength);
|
|
513
|
+
crypto.getRandomValues(bytes);
|
|
514
|
+
return bytesToBase64Url(bytes);
|
|
515
|
+
}
|
|
516
|
+
async function sha256Base64Url(value) {
|
|
517
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
518
|
+
return bytesToBase64Url(new Uint8Array(digest));
|
|
519
|
+
}
|
|
520
|
+
async function parseJsonObject(request) {
|
|
521
|
+
try {
|
|
522
|
+
const body = await request.json();
|
|
523
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
|
|
524
|
+
return null;
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function getOAuthPkceCookieName(ctx) {
|
|
530
|
+
return `__${ctx.namespace}_oauth_pkce`;
|
|
531
|
+
}
|
|
532
|
+
function encodeOAuthPkceCookie(value) {
|
|
533
|
+
return encodeURIComponent(JSON.stringify(value));
|
|
534
|
+
}
|
|
535
|
+
function readOAuthPkceVerifier(request, ctx) {
|
|
536
|
+
const raw = request.cookies.get(getOAuthPkceCookieName(ctx))?.value;
|
|
537
|
+
if (!raw) return null;
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(decodeURIComponent(raw));
|
|
540
|
+
if (typeof parsed.verifier !== "string" || parsed.verifier.length < 43) return null;
|
|
541
|
+
if (typeof parsed.createdAt !== "number") return null;
|
|
542
|
+
if (Date.now() - parsed.createdAt > OAUTH_PKCE_TTL_MS) return null;
|
|
543
|
+
return parsed.verifier;
|
|
544
|
+
} catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function setOAuthPkceCookie(response, ctx, value) {
|
|
549
|
+
response.cookies.set(getOAuthPkceCookieName(ctx), encodeOAuthPkceCookie(value), {
|
|
550
|
+
...SECURE_COOKIE_OPTIONS,
|
|
551
|
+
maxAge: OAUTH_PKCE_TTL_SECONDS,
|
|
552
|
+
path: ctx.config.authPrefix
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function clearOAuthPkceCookie(response, ctx) {
|
|
556
|
+
response.cookies.set(getOAuthPkceCookieName(ctx), "", {
|
|
557
|
+
...SECURE_COOKIE_OPTIONS,
|
|
558
|
+
maxAge: 0,
|
|
559
|
+
path: ctx.config.authPrefix
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
function isTwoFactorLoginResponse(data) {
|
|
563
|
+
return typeof data === "object" && data !== null && data.requiresTwoFactor === true && typeof data.userId === "string";
|
|
564
|
+
}
|
|
565
|
+
function isOAuthAuthorizeResponse(data) {
|
|
566
|
+
return typeof data === "object" && data !== null && typeof data.authorization_url === "string";
|
|
567
|
+
}
|
|
568
|
+
function headersForPlatformAuth(ctx) {
|
|
569
|
+
return {
|
|
570
|
+
"Content-Type": "application/json",
|
|
571
|
+
"x-app-secret": ctx.secretKey
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async function handleLogin(request, ctx) {
|
|
575
|
+
if (request.method !== "POST") {
|
|
576
|
+
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
|
577
|
+
}
|
|
578
|
+
const body = await parseJsonObject(request);
|
|
579
|
+
const email = typeof body?.email === "string" ? body.email : null;
|
|
580
|
+
const password = typeof body?.password === "string" ? body.password : null;
|
|
581
|
+
if (!email || !password) {
|
|
582
|
+
return NextResponse.json({ error: "email and password are required" }, { status: 400 });
|
|
583
|
+
}
|
|
584
|
+
const res = await fetch(`${ctx.platformUrl}/v1/auth/login`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: headersForPlatformAuth(ctx),
|
|
587
|
+
body: JSON.stringify({ email, password })
|
|
588
|
+
});
|
|
589
|
+
const data = await res.json().catch(() => ({}));
|
|
590
|
+
if (!res.ok) {
|
|
591
|
+
return NextResponse.json(data, { status: res.status });
|
|
592
|
+
}
|
|
593
|
+
if (isTwoFactorLoginResponse(data)) {
|
|
594
|
+
return NextResponse.json(data);
|
|
595
|
+
}
|
|
596
|
+
if (!isTokenResponse(data)) {
|
|
597
|
+
return NextResponse.json({ error: "invalid_response" }, { status: 502 });
|
|
598
|
+
}
|
|
599
|
+
const response = NextResponse.json({ success: true, user: data.user });
|
|
600
|
+
setAuthCookiesMiddleware(response, ctx.namespace, data);
|
|
601
|
+
return response;
|
|
602
|
+
}
|
|
603
|
+
async function handleOAuthProviders(ctx) {
|
|
604
|
+
const res = await fetch(`${ctx.platformUrl}/v1/auth/oauth-providers`, {
|
|
605
|
+
method: "GET",
|
|
606
|
+
headers: headersForPlatformAuth(ctx)
|
|
607
|
+
});
|
|
608
|
+
const data = await res.json().catch(() => ({}));
|
|
609
|
+
return NextResponse.json(data, { status: res.status });
|
|
610
|
+
}
|
|
611
|
+
async function handleOAuthAuthorize(request, ctx) {
|
|
612
|
+
if (request.method !== "GET" && request.method !== "POST") {
|
|
613
|
+
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
|
614
|
+
}
|
|
615
|
+
const body = request.method === "POST" ? await parseJsonObject(request) : null;
|
|
616
|
+
const provider = typeof body?.provider === "string" ? body.provider : request.nextUrl.searchParams.get("provider");
|
|
617
|
+
const rawRedirectTo = typeof body?.redirectTo === "string" ? body.redirectTo : request.nextUrl.searchParams.get("redirect_to") ?? request.nextUrl.searchParams.get("callbackUrl");
|
|
618
|
+
if (!provider) {
|
|
619
|
+
return NextResponse.json({ error: "provider is required" }, { status: 400 });
|
|
620
|
+
}
|
|
621
|
+
const redirectTo = resolveSafeRelativeRedirectPath(rawRedirectTo, ctx.config.afterSignInUrl);
|
|
622
|
+
const redirectUri = new URL(`${ctx.config.authPrefix}/callback`, request.url);
|
|
623
|
+
redirectUri.searchParams.set("redirect_to", redirectTo);
|
|
624
|
+
const verifier = randomBase64Url(32);
|
|
625
|
+
const challenge = await sha256Base64Url(verifier);
|
|
626
|
+
const scopes = Array.isArray(body?.scopes) ? body.scopes.filter((scope) => typeof scope === "string") : void 0;
|
|
627
|
+
const res = await fetch(`${ctx.platformUrl}/v1/oauth/authorize`, {
|
|
628
|
+
method: "POST",
|
|
629
|
+
headers: headersForPlatformAuth(ctx),
|
|
630
|
+
body: JSON.stringify({
|
|
631
|
+
provider,
|
|
632
|
+
redirect_uri: redirectUri.toString(),
|
|
633
|
+
code_challenge: challenge,
|
|
634
|
+
code_challenge_method: "S256",
|
|
635
|
+
...scopes && scopes.length > 0 ? { scopes } : {}
|
|
636
|
+
})
|
|
637
|
+
});
|
|
638
|
+
const data = await res.json().catch(() => ({}));
|
|
639
|
+
if (!res.ok || !isOAuthAuthorizeResponse(data)) {
|
|
640
|
+
return NextResponse.json(res.ok ? { error: "invalid_response" } : data, {
|
|
641
|
+
status: res.ok ? 502 : res.status
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const response = request.method === "GET" ? NextResponse.redirect(data.authorization_url) : NextResponse.json({
|
|
645
|
+
authorization_url: data.authorization_url,
|
|
646
|
+
authorizationUrl: data.authorization_url
|
|
647
|
+
});
|
|
648
|
+
setOAuthPkceCookie(response, ctx, { verifier, createdAt: Date.now() });
|
|
649
|
+
return response;
|
|
650
|
+
}
|
|
479
651
|
async function handleCallback(request, ctx) {
|
|
480
652
|
const { searchParams } = request.nextUrl;
|
|
481
653
|
const code = searchParams.get("code");
|
|
@@ -495,30 +667,37 @@ async function handleCallback(request, ctx) {
|
|
|
495
667
|
return NextResponse.redirect(url);
|
|
496
668
|
}
|
|
497
669
|
try {
|
|
670
|
+
const codeVerifier = readOAuthPkceVerifier(request, ctx);
|
|
498
671
|
const res = await fetch(`${ctx.platformUrl}/v1/auth/token`, {
|
|
499
672
|
method: "POST",
|
|
500
673
|
headers: { "Content-Type": "application/json" },
|
|
501
674
|
body: JSON.stringify({
|
|
502
675
|
grant_type: "authorization_code",
|
|
503
676
|
code,
|
|
504
|
-
client_secret: ctx.secretKey
|
|
677
|
+
client_secret: ctx.secretKey,
|
|
678
|
+
...codeVerifier ? { code_verifier: codeVerifier } : {}
|
|
505
679
|
})
|
|
506
680
|
});
|
|
507
681
|
if (!res.ok) {
|
|
508
682
|
const data2 = await res.json().catch(() => ({}));
|
|
509
683
|
const url = new URL(ctx.config.signInUrl, request.url);
|
|
510
684
|
url.searchParams.set("error", data2.error || "token_exchange_failed");
|
|
511
|
-
|
|
685
|
+
const response2 = NextResponse.redirect(url);
|
|
686
|
+
clearOAuthPkceCookie(response2, ctx);
|
|
687
|
+
return response2;
|
|
512
688
|
}
|
|
513
689
|
const data = await res.json();
|
|
514
690
|
if (!isTokenResponse(data)) {
|
|
515
691
|
const url = new URL(ctx.config.signInUrl, request.url);
|
|
516
692
|
url.searchParams.set("error", "invalid_response");
|
|
517
|
-
|
|
693
|
+
const response2 = NextResponse.redirect(url);
|
|
694
|
+
clearOAuthPkceCookie(response2, ctx);
|
|
695
|
+
return response2;
|
|
518
696
|
}
|
|
519
697
|
const successUrl = new URL(redirectTo, request.url);
|
|
520
698
|
const response = NextResponse.redirect(successUrl);
|
|
521
699
|
setAuthCookiesMiddleware(response, ctx.namespace, data);
|
|
700
|
+
clearOAuthPkceCookie(response, ctx);
|
|
522
701
|
ctx.log("Callback success", { redirectTo });
|
|
523
702
|
return response;
|
|
524
703
|
} catch (err) {
|
|
@@ -569,6 +748,16 @@ function resolveOrgScopedTokenExpiresIn(data) {
|
|
|
569
748
|
if (typeof data.expires_in === "number") return data.expires_in;
|
|
570
749
|
return SESSION_TOKEN_LIFETIME;
|
|
571
750
|
}
|
|
751
|
+
function resolveOrgScopedAccessToken(data) {
|
|
752
|
+
if (data.accessToken) return data.accessToken;
|
|
753
|
+
if (data.access_token) return data.access_token;
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
function resolveOrgScopedRefreshToken(data, fallbackRefreshToken) {
|
|
757
|
+
if (data.refreshToken) return data.refreshToken;
|
|
758
|
+
if (data.refresh_token) return data.refresh_token;
|
|
759
|
+
return fallbackRefreshToken;
|
|
760
|
+
}
|
|
572
761
|
function resolveOrgScopedTokenType(data) {
|
|
573
762
|
if (data.tokenType) return data.tokenType;
|
|
574
763
|
if (data.token_type) return data.token_type;
|
|
@@ -577,11 +766,149 @@ function resolveOrgScopedTokenType(data) {
|
|
|
577
766
|
function parseUserCookie2(value) {
|
|
578
767
|
if (!value) return null;
|
|
579
768
|
try {
|
|
580
|
-
return JSON.parse(value);
|
|
769
|
+
return JSON.parse(decodeCookieValue(value));
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function decodeCookieValue(value) {
|
|
775
|
+
try {
|
|
776
|
+
return decodeURIComponent(value);
|
|
581
777
|
} catch {
|
|
778
|
+
return value;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function readFirstCookieValue(request, names) {
|
|
782
|
+
for (const name of names) {
|
|
783
|
+
const value = request.cookies.get(name)?.value;
|
|
784
|
+
if (value) return decodeCookieValue(value);
|
|
785
|
+
}
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
function uniqueCookieNames(names) {
|
|
789
|
+
return [...new Set(names.filter((name) => Boolean(name)))];
|
|
790
|
+
}
|
|
791
|
+
function readActiveOrganizationContext(request, ctx, sessionToken) {
|
|
792
|
+
const orgIdCookieNames = uniqueCookieNames([
|
|
793
|
+
ctx.cookieNames.ACTIVE_ORG_ID,
|
|
794
|
+
...ctx.config.organizationContext.additionalOrgIdCookies
|
|
795
|
+
]);
|
|
796
|
+
const orgSlugCookieNames = uniqueCookieNames([
|
|
797
|
+
ctx.cookieNames.ACTIVE_ORG_SLUG,
|
|
798
|
+
...ctx.config.organizationContext.additionalOrgSlugCookies
|
|
799
|
+
]);
|
|
800
|
+
const fromCookie = {
|
|
801
|
+
id: readFirstCookieValue(request, orgIdCookieNames),
|
|
802
|
+
slug: readFirstCookieValue(request, orgSlugCookieNames)
|
|
803
|
+
};
|
|
804
|
+
if (fromCookie.id || fromCookie.slug) return fromCookie;
|
|
805
|
+
const payload = sessionToken ? decodeJwtPayload(sessionToken) : null;
|
|
806
|
+
return {
|
|
807
|
+
id: payload?.org_id ?? null,
|
|
808
|
+
slug: payload?.org_slug ?? null
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function setActiveOrganizationCookies(response, ctx, org) {
|
|
812
|
+
if (org.id) {
|
|
813
|
+
response.cookies.set(ctx.cookieNames.ACTIVE_ORG_ID, org.id, {
|
|
814
|
+
...ACTIVE_ORG_COOKIE_OPTIONS,
|
|
815
|
+
maxAge: ACTIVE_ORG_LIFETIME
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (org.slug) {
|
|
819
|
+
response.cookies.set(ctx.cookieNames.ACTIVE_ORG_SLUG, org.slug, {
|
|
820
|
+
...ACTIVE_ORG_COOKIE_OPTIONS,
|
|
821
|
+
maxAge: ACTIVE_ORG_LIFETIME
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async function getUserOrganizations(accessToken, ctx) {
|
|
826
|
+
try {
|
|
827
|
+
const res = await fetch(`${ctx.platformUrl}/v1/orgs/memberships`, {
|
|
828
|
+
method: "GET",
|
|
829
|
+
headers: {
|
|
830
|
+
"Content-Type": "application/json",
|
|
831
|
+
"x-app-secret": ctx.secretKey,
|
|
832
|
+
Authorization: `Bearer ${accessToken}`
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
if (!res.ok) {
|
|
836
|
+
ctx.log("Organization memberships fetch failed", res.status);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const data = await res.json().catch(() => ({}));
|
|
840
|
+
return Array.isArray(data.organizations) ? data.organizations : null;
|
|
841
|
+
} catch (err) {
|
|
842
|
+
ctx.log("Organization memberships fetch error", err);
|
|
582
843
|
return null;
|
|
583
844
|
}
|
|
584
845
|
}
|
|
846
|
+
async function getOrgScopedTokens(accessToken, orgId, ctx) {
|
|
847
|
+
try {
|
|
848
|
+
const res = await fetch(`${ctx.platformUrl}/v1/auth/switch-org`, {
|
|
849
|
+
method: "POST",
|
|
850
|
+
headers: {
|
|
851
|
+
"Content-Type": "application/json",
|
|
852
|
+
"x-app-secret": ctx.secretKey,
|
|
853
|
+
Authorization: `Bearer ${accessToken}`
|
|
854
|
+
},
|
|
855
|
+
body: JSON.stringify({ orgId })
|
|
856
|
+
});
|
|
857
|
+
const data = await res.json().catch(() => ({}));
|
|
858
|
+
if (!res.ok) {
|
|
859
|
+
ctx.log("Org scope restore failed", data.error || data.message || res.status);
|
|
860
|
+
return { status: res.status, data };
|
|
861
|
+
}
|
|
862
|
+
return { status: res.status, data };
|
|
863
|
+
} catch (err) {
|
|
864
|
+
ctx.log("Org scope restore error", err);
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async function restoreOrganizationScopeAfterRefresh(request, ctx, refreshedTokens, previousSessionToken) {
|
|
869
|
+
if (!ctx.config.organizationContext.enabled) {
|
|
870
|
+
return { tokens: refreshedTokens, activeOrganization: null };
|
|
871
|
+
}
|
|
872
|
+
const activeOrg = readActiveOrganizationContext(request, ctx, previousSessionToken);
|
|
873
|
+
let targetOrgId = activeOrg.id;
|
|
874
|
+
let targetOrgSlug = activeOrg.slug;
|
|
875
|
+
let organizations = null;
|
|
876
|
+
if (!targetOrgId && targetOrgSlug) {
|
|
877
|
+
organizations = await getUserOrganizations(refreshedTokens.accessToken, ctx);
|
|
878
|
+
targetOrgId = organizations?.find((org) => org.slug === targetOrgSlug)?.id ?? null;
|
|
879
|
+
}
|
|
880
|
+
if (!targetOrgId && !targetOrgSlug && ctx.config.organizationContext.autoSelectSingleOrganization) {
|
|
881
|
+
organizations = organizations ?? await getUserOrganizations(refreshedTokens.accessToken, ctx);
|
|
882
|
+
const onlyOrg = organizations?.length === 1 ? organizations[0] : null;
|
|
883
|
+
targetOrgId = onlyOrg?.id ?? null;
|
|
884
|
+
targetOrgSlug = onlyOrg?.slug ?? null;
|
|
885
|
+
}
|
|
886
|
+
if (!targetOrgId) {
|
|
887
|
+
return { tokens: refreshedTokens, activeOrganization: null };
|
|
888
|
+
}
|
|
889
|
+
const scoped = await getOrgScopedTokens(refreshedTokens.accessToken, targetOrgId, ctx);
|
|
890
|
+
const scopedData = scoped?.data;
|
|
891
|
+
const accessToken = scopedData ? resolveOrgScopedAccessToken(scopedData) : null;
|
|
892
|
+
if (!scoped || scoped.status >= 400 || !scopedData || !accessToken) {
|
|
893
|
+
return { tokens: refreshedTokens, activeOrganization: null };
|
|
894
|
+
}
|
|
895
|
+
const payload = decodeJwtPayload(accessToken);
|
|
896
|
+
const resolvedOrg = {
|
|
897
|
+
id: payload?.org_id ?? targetOrgId,
|
|
898
|
+
slug: payload?.org_slug ?? targetOrgSlug
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
tokens: {
|
|
902
|
+
...refreshedTokens,
|
|
903
|
+
accessToken,
|
|
904
|
+
refreshToken: resolveOrgScopedRefreshToken(scopedData, refreshedTokens.refreshToken),
|
|
905
|
+
expiresIn: resolveOrgScopedTokenExpiresIn(scopedData),
|
|
906
|
+
tokenType: resolveOrgScopedTokenType(scopedData),
|
|
907
|
+
user: scopedData.user ?? refreshedTokens.user
|
|
908
|
+
},
|
|
909
|
+
activeOrganization: resolvedOrg
|
|
910
|
+
};
|
|
911
|
+
}
|
|
585
912
|
async function handleSwitchOrg(request, ctx) {
|
|
586
913
|
ctx.log("Switch org request");
|
|
587
914
|
if (request.method !== "POST") {
|
|
@@ -592,9 +919,11 @@ async function handleSwitchOrg(request, ctx) {
|
|
|
592
919
|
return NextResponse.json({ error: "Not authenticated", accessToken: null }, { status: 401 });
|
|
593
920
|
}
|
|
594
921
|
let orgId = null;
|
|
922
|
+
let requestedOrgSlug = null;
|
|
595
923
|
try {
|
|
596
924
|
const body = await request.json();
|
|
597
925
|
orgId = typeof body.orgId === "string" && body.orgId.trim() ? body.orgId.trim() : null;
|
|
926
|
+
requestedOrgSlug = typeof body.orgSlug === "string" && body.orgSlug.trim() ? body.orgSlug.trim() : null;
|
|
598
927
|
} catch {
|
|
599
928
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
600
929
|
}
|
|
@@ -602,25 +931,20 @@ async function handleSwitchOrg(request, ctx) {
|
|
|
602
931
|
return NextResponse.json({ error: "orgId is required" }, { status: 400 });
|
|
603
932
|
}
|
|
604
933
|
try {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
"Content-Type": "application/json",
|
|
609
|
-
"x-app-secret": ctx.secretKey,
|
|
610
|
-
Authorization: `Bearer ${sessionToken}`
|
|
611
|
-
},
|
|
612
|
-
body: JSON.stringify({ orgId })
|
|
613
|
-
});
|
|
614
|
-
const data = await res.json().catch(() => ({}));
|
|
615
|
-
if (!res.ok) {
|
|
934
|
+
const scoped = await getOrgScopedTokens(sessionToken, orgId, ctx);
|
|
935
|
+
const data = scoped?.data;
|
|
936
|
+
if (!scoped || scoped.status >= 400 || !data) {
|
|
616
937
|
return NextResponse.json(
|
|
617
|
-
{ error: data
|
|
618
|
-
{ status:
|
|
938
|
+
{ error: data?.error || data?.message || "switch_org_failed" },
|
|
939
|
+
{ status: scoped?.status ?? 502 }
|
|
619
940
|
);
|
|
620
941
|
}
|
|
621
|
-
const accessToken = data
|
|
942
|
+
const accessToken = resolveOrgScopedAccessToken(data);
|
|
622
943
|
if (!accessToken) {
|
|
623
|
-
return NextResponse.json(
|
|
944
|
+
return NextResponse.json(
|
|
945
|
+
{ error: data.error || data.message || "invalid_response" },
|
|
946
|
+
{ status: 502 }
|
|
947
|
+
);
|
|
624
948
|
}
|
|
625
949
|
const expiresIn = resolveOrgScopedTokenExpiresIn(data);
|
|
626
950
|
const expiresAt = Date.now() + expiresIn * 1e3;
|
|
@@ -650,6 +974,11 @@ async function handleSwitchOrg(request, ctx) {
|
|
|
650
974
|
}
|
|
651
975
|
);
|
|
652
976
|
}
|
|
977
|
+
const payload = decodeJwtPayload(accessToken);
|
|
978
|
+
setActiveOrganizationCookies(response, ctx, {
|
|
979
|
+
id: payload?.org_id ?? orgId,
|
|
980
|
+
slug: payload?.org_slug ?? requestedOrgSlug
|
|
981
|
+
});
|
|
653
982
|
ctx.log("Switch org success", { orgId });
|
|
654
983
|
return response;
|
|
655
984
|
} catch (err) {
|
|
@@ -724,6 +1053,12 @@ function createSylphxMiddleware(userConfig = {}) {
|
|
|
724
1053
|
debug: userConfig.debug ?? false,
|
|
725
1054
|
orgRequired: userConfig.orgRequired ?? false,
|
|
726
1055
|
orgSelectionUrl: userConfig.orgSelectionUrl ?? "/select-organization",
|
|
1056
|
+
organizationContext: {
|
|
1057
|
+
enabled: userConfig.organizationContext?.enabled ?? true,
|
|
1058
|
+
autoSelectSingleOrganization: userConfig.organizationContext?.autoSelectSingleOrganization ?? true,
|
|
1059
|
+
additionalOrgIdCookies: userConfig.organizationContext?.additionalOrgIdCookies ?? [],
|
|
1060
|
+
additionalOrgSlugCookies: userConfig.organizationContext?.additionalOrgSlugCookies ?? []
|
|
1061
|
+
},
|
|
727
1062
|
onResponse: userConfig.onResponse
|
|
728
1063
|
};
|
|
729
1064
|
const publicRoutes = [
|
|
@@ -766,6 +1101,15 @@ function createSylphxMiddleware(userConfig = {}) {
|
|
|
766
1101
|
if (pathname === `${config.authPrefix}/callback`) {
|
|
767
1102
|
return handleCallback(request, ctx);
|
|
768
1103
|
}
|
|
1104
|
+
if (pathname === `${config.authPrefix}/login`) {
|
|
1105
|
+
return handleLogin(request, ctx);
|
|
1106
|
+
}
|
|
1107
|
+
if (pathname === `${config.authPrefix}/oauth-providers`) {
|
|
1108
|
+
return handleOAuthProviders(ctx);
|
|
1109
|
+
}
|
|
1110
|
+
if (pathname === `${config.authPrefix}/oauth/authorize`) {
|
|
1111
|
+
return handleOAuthAuthorize(request, ctx);
|
|
1112
|
+
}
|
|
769
1113
|
if (pathname === `${config.authPrefix}/signout`) {
|
|
770
1114
|
return handleSignOut(request, ctx);
|
|
771
1115
|
}
|
|
@@ -778,16 +1122,25 @@ function createSylphxMiddleware(userConfig = {}) {
|
|
|
778
1122
|
const sessionToken = request.cookies.get(cookieNames.SESSION)?.value;
|
|
779
1123
|
const refreshToken = request.cookies.get(cookieNames.REFRESH)?.value;
|
|
780
1124
|
const hasValidSession = sessionToken && !isTokenExpired(sessionToken);
|
|
781
|
-
const response = NextResponse.next();
|
|
1125
|
+
const response = NextResponse.next({ request: { headers: request.headers } });
|
|
782
1126
|
let isAuthenticated = hasValidSession;
|
|
783
1127
|
let activeSessionToken = hasValidSession ? sessionToken : null;
|
|
784
1128
|
if (!hasValidSession && refreshToken) {
|
|
785
1129
|
log("Session expired, refreshing");
|
|
786
1130
|
const tokens = await refreshTokens(refreshToken, ctx);
|
|
787
1131
|
if (tokens) {
|
|
788
|
-
|
|
1132
|
+
const restored = await restoreOrganizationScopeAfterRefresh(
|
|
1133
|
+
request,
|
|
1134
|
+
ctx,
|
|
1135
|
+
tokens,
|
|
1136
|
+
sessionToken
|
|
1137
|
+
);
|
|
1138
|
+
setAuthCookiesMiddleware(response, namespace, restored.tokens);
|
|
1139
|
+
if (restored.activeOrganization) {
|
|
1140
|
+
setActiveOrganizationCookies(response, ctx, restored.activeOrganization);
|
|
1141
|
+
}
|
|
789
1142
|
isAuthenticated = true;
|
|
790
|
-
activeSessionToken = tokens.accessToken;
|
|
1143
|
+
activeSessionToken = restored.tokens.accessToken;
|
|
791
1144
|
} else {
|
|
792
1145
|
clearAuthCookiesMiddleware(response, namespace);
|
|
793
1146
|
isAuthenticated = false;
|
|
@@ -798,19 +1151,23 @@ function createSylphxMiddleware(userConfig = {}) {
|
|
|
798
1151
|
if (!isPublic && !isAuthenticated) {
|
|
799
1152
|
log("Redirecting to sign-in");
|
|
800
1153
|
const url = new URL(config.signInUrl, request.url);
|
|
801
|
-
url.searchParams.set("
|
|
1154
|
+
url.searchParams.set("callbackUrl", requestPathWithSearch(request));
|
|
802
1155
|
return NextResponse.redirect(url);
|
|
803
1156
|
}
|
|
804
1157
|
if (isAuthenticated && pathname === config.signInUrl) {
|
|
805
|
-
|
|
806
|
-
|
|
1158
|
+
const callbackUrl = resolveSafeRelativeRedirectPath(
|
|
1159
|
+
request.nextUrl.searchParams.get("callbackUrl"),
|
|
1160
|
+
config.afterSignInUrl
|
|
1161
|
+
);
|
|
1162
|
+
log("Redirecting from sign-in", { callbackUrl });
|
|
1163
|
+
return NextResponse.redirect(new URL(callbackUrl, request.url));
|
|
807
1164
|
}
|
|
808
1165
|
if (config.orgRequired && !isPublic && isAuthenticated && activeSessionToken && !matchesPattern(pathname, config.orgSelectionUrl)) {
|
|
809
1166
|
const payload = decodeJwtPayload(activeSessionToken);
|
|
810
1167
|
if (!payload?.org_id) {
|
|
811
1168
|
log("Redirecting to organization selection");
|
|
812
1169
|
const url = new URL(config.orgSelectionUrl, request.url);
|
|
813
|
-
url.searchParams.set("redirect_to",
|
|
1170
|
+
url.searchParams.set("redirect_to", requestPathWithSearch(request));
|
|
814
1171
|
return NextResponse.redirect(url);
|
|
815
1172
|
}
|
|
816
1173
|
}
|
|
@@ -2263,6 +2620,8 @@ async function getSessionToken() {
|
|
|
2263
2620
|
return sessionToken;
|
|
2264
2621
|
}
|
|
2265
2622
|
export {
|
|
2623
|
+
ACTIVE_ORG_COOKIE_OPTIONS,
|
|
2624
|
+
ACTIVE_ORG_LIFETIME,
|
|
2266
2625
|
REFRESH_TOKEN_LIFETIME,
|
|
2267
2626
|
SECURE_COOKIE_OPTIONS,
|
|
2268
2627
|
SESSION_TOKEN_LIFETIME,
|