@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.
@@ -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 };
@@ -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
- return NextResponse.redirect(url);
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
- return NextResponse.redirect(url);
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 res = await fetch(`${ctx.platformUrl}/v1/auth/switch-org`, {
606
- method: "POST",
607
- headers: {
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.error || data.message || "switch_org_failed" },
618
- { status: res.status }
938
+ { error: data?.error || data?.message || "switch_org_failed" },
939
+ { status: scoped?.status ?? 502 }
619
940
  );
620
941
  }
621
- const accessToken = data.accessToken ?? data.access_token;
942
+ const accessToken = resolveOrgScopedAccessToken(data);
622
943
  if (!accessToken) {
623
- return NextResponse.json({ error: "invalid_response" }, { status: 502 });
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
- setAuthCookiesMiddleware(response, namespace, tokens);
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("redirect_to", pathname);
1154
+ url.searchParams.set("callbackUrl", requestPathWithSearch(request));
802
1155
  return NextResponse.redirect(url);
803
1156
  }
804
1157
  if (isAuthenticated && pathname === config.signInUrl) {
805
- log("Redirecting from sign-in to dashboard");
806
- return NextResponse.redirect(new URL(config.afterSignInUrl, request.url));
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", pathname);
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,