@sylphx/sdk 0.10.4 → 0.10.6

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.
@@ -6,6 +6,7 @@ import { AuthTokensResponse } from '@sylphx/contract';
6
6
  *
7
7
  * ONE middleware function handles EVERYTHING:
8
8
  * - Auth routes (mounted automatically, zero manual API routes)
9
+ * - BaaS routes (same-origin proxy, no browser bearer-token exposure)
9
10
  * - Token refresh (automatic, every request)
10
11
  * - Route protection
11
12
  * - Cookie management
@@ -63,12 +64,32 @@ interface SylphxMiddlewareConfig {
63
64
  afterSignInUrl?: string;
64
65
  /**
65
66
  * Auth routes prefix. Routes are mounted at:
67
+ * - {prefix}/login — credentials login handler
68
+ * - {prefix}/oauth-providers — enabled social login providers
69
+ * - {prefix}/oauth/authorize — social login start handler
66
70
  * - {prefix}/callback — OAuth callback handler
71
+ * - {prefix}/passkey/options — passkey login challenge handler
72
+ * - {prefix}/passkey/authenticate — passkey login verification handler
73
+ * - {prefix}/forgot-password — password reset email handler
74
+ * - {prefix}/reset-password — password reset verification handler
67
75
  * - {prefix}/signout — Sign out handler
68
76
  *
69
77
  * @default '/auth'
70
78
  */
71
79
  authPrefix?: string;
80
+ /**
81
+ * Same-origin BaaS proxy prefix.
82
+ *
83
+ * Mounted routes are allowlisted SDK-owned BaaS surfaces such as
84
+ * `{prefix}/security/*` and `{prefix}/challenge/*`. The middleware converts
85
+ * the HttpOnly session cookie into an upstream bearer token, so browser code
86
+ * can use BaaS features without ever reading Platform access tokens.
87
+ *
88
+ * Set to `false` to disable the proxy.
89
+ *
90
+ * @default '/sylphx'
91
+ */
92
+ baasPrefix?: string | false;
72
93
  /**
73
94
  * Enable debug logging.
74
95
  * @default false
@@ -453,6 +453,21 @@ function resolveNextjsSecretKey(options) {
453
453
  }
454
454
 
455
455
  // src/nextjs/middleware.ts
456
+ var OAUTH_PKCE_TTL_SECONDS = 10 * 60;
457
+ var OAUTH_PKCE_TTL_MS = OAUTH_PKCE_TTL_SECONDS * 1e3;
458
+ var DEFAULT_BAAS_PREFIX = "/sylphx";
459
+ var BAAS_PROXY_AUTH_REQUIRED_PATHS = ["/challenge/", "/security/"];
460
+ var BAAS_PROXY_PUBLIC_PATHS = ["/app"];
461
+ var BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
462
+ var RESPONSE_HEADER_ALLOWLIST = [
463
+ "cache-control",
464
+ "content-language",
465
+ "content-type",
466
+ "etag",
467
+ "expires",
468
+ "last-modified",
469
+ "retry-after"
470
+ ];
456
471
  function isTokenResponse(data) {
457
472
  return typeof data === "object" && data !== null && "accessToken" in data && "refreshToken" in data && "user" in data && typeof data.accessToken === "string" && typeof data.refreshToken === "string";
458
473
  }
@@ -489,6 +504,46 @@ function matchesPattern(pathname, pattern) {
489
504
  function matchesAny(pathname, patterns) {
490
505
  return patterns.some((p) => matchesPattern(pathname, p));
491
506
  }
507
+ function normalizeRoutePrefix(prefix) {
508
+ const normalized = prefix.trim().replace(/\/+$/u, "");
509
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
510
+ }
511
+ function stripRoutePrefix(pathname, prefix) {
512
+ if (pathname === prefix) return "/";
513
+ if (!pathname.startsWith(`${prefix}/`)) return null;
514
+ return pathname.slice(prefix.length);
515
+ }
516
+ function isPublicBaasProxyPath(pathname) {
517
+ return BAAS_PROXY_PUBLIC_PATHS.some((path) => pathname === path);
518
+ }
519
+ function isAuthenticatedBaasProxyPath(pathname) {
520
+ return BAAS_PROXY_AUTH_REQUIRED_PATHS.some((prefix) => pathname.startsWith(prefix));
521
+ }
522
+ function isAllowedBaasProxyPath(pathname) {
523
+ return isPublicBaasProxyPath(pathname) || isAuthenticatedBaasProxyPath(pathname);
524
+ }
525
+ function copyRequestHeader(source, target, name) {
526
+ const value = source.get(name);
527
+ if (value) target.set(name, value);
528
+ }
529
+ function buildUpstreamProxyHeaders(request, ctx, sessionToken) {
530
+ const headers = new Headers();
531
+ copyRequestHeader(request.headers, headers, "accept");
532
+ copyRequestHeader(request.headers, headers, "content-type");
533
+ copyRequestHeader(request.headers, headers, "user-agent");
534
+ copyRequestHeader(request.headers, headers, "x-correlation-id");
535
+ headers.set("x-app-secret", ctx.secretKey);
536
+ if (sessionToken) headers.set("authorization", `Bearer ${sessionToken}`);
537
+ return headers;
538
+ }
539
+ function copyResponseHeaders(source) {
540
+ const headers = new Headers();
541
+ for (const name of RESPONSE_HEADER_ALLOWLIST) {
542
+ const value = source.get(name);
543
+ if (value) headers.set(name, value);
544
+ }
545
+ return headers;
546
+ }
492
547
  function requestPathWithSearch(request) {
493
548
  const { pathname, search } = request.nextUrl;
494
549
  return `${pathname}${search}`;
@@ -501,6 +556,241 @@ function resolveSafeRelativeRedirectPath(value, fallback) {
501
556
  if (isSafeRelativeRedirectPath(fallback)) return fallback;
502
557
  return "/";
503
558
  }
559
+ function resolveSameOriginUrl(request, value, fallbackPath) {
560
+ if (!value) return new URL(fallbackPath, request.url).toString();
561
+ if (isSafeRelativeRedirectPath(value)) {
562
+ const url = new URL(value, request.url);
563
+ if (url.hash) return null;
564
+ return url.toString();
565
+ }
566
+ try {
567
+ const url = new URL(value);
568
+ if (url.origin !== new URL(request.url).origin) return null;
569
+ if (url.hash) return null;
570
+ return url.toString();
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+ function bytesToBase64Url(bytes) {
576
+ let binary = "";
577
+ for (const byte of bytes) binary += String.fromCharCode(byte);
578
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
579
+ }
580
+ function randomBase64Url(byteLength) {
581
+ const bytes = new Uint8Array(byteLength);
582
+ crypto.getRandomValues(bytes);
583
+ return bytesToBase64Url(bytes);
584
+ }
585
+ async function sha256Base64Url(value) {
586
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
587
+ return bytesToBase64Url(new Uint8Array(digest));
588
+ }
589
+ async function parseJsonObject(request) {
590
+ try {
591
+ const body = await request.json();
592
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
593
+ return null;
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+ function getOAuthPkceCookieName(ctx) {
599
+ return `__${ctx.namespace}_oauth_pkce`;
600
+ }
601
+ function encodeOAuthPkceCookie(value) {
602
+ return encodeURIComponent(JSON.stringify(value));
603
+ }
604
+ function readOAuthPkceVerifier(request, ctx) {
605
+ const raw = request.cookies.get(getOAuthPkceCookieName(ctx))?.value;
606
+ if (!raw) return null;
607
+ try {
608
+ const parsed = JSON.parse(decodeURIComponent(raw));
609
+ if (typeof parsed.verifier !== "string" || parsed.verifier.length < 43) return null;
610
+ if (typeof parsed.createdAt !== "number") return null;
611
+ if (Date.now() - parsed.createdAt > OAUTH_PKCE_TTL_MS) return null;
612
+ return parsed.verifier;
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+ function setOAuthPkceCookie(response, ctx, value) {
618
+ response.cookies.set(getOAuthPkceCookieName(ctx), encodeOAuthPkceCookie(value), {
619
+ ...SECURE_COOKIE_OPTIONS,
620
+ maxAge: OAUTH_PKCE_TTL_SECONDS,
621
+ path: ctx.config.authPrefix
622
+ });
623
+ }
624
+ function clearOAuthPkceCookie(response, ctx) {
625
+ response.cookies.set(getOAuthPkceCookieName(ctx), "", {
626
+ ...SECURE_COOKIE_OPTIONS,
627
+ maxAge: 0,
628
+ path: ctx.config.authPrefix
629
+ });
630
+ }
631
+ function isTwoFactorLoginResponse(data) {
632
+ return typeof data === "object" && data !== null && data.requiresTwoFactor === true && typeof data.userId === "string";
633
+ }
634
+ function isOAuthAuthorizeResponse(data) {
635
+ return typeof data === "object" && data !== null && typeof data.authorization_url === "string";
636
+ }
637
+ function headersForPlatformAuth(ctx) {
638
+ return {
639
+ "Content-Type": "application/json",
640
+ "x-app-secret": ctx.secretKey
641
+ };
642
+ }
643
+ async function handleLogin(request, ctx) {
644
+ if (request.method !== "POST") {
645
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
646
+ }
647
+ const body = await parseJsonObject(request);
648
+ const email = typeof body?.email === "string" ? body.email : null;
649
+ const password = typeof body?.password === "string" ? body.password : null;
650
+ if (!email || !password) {
651
+ return NextResponse.json({ error: "email and password are required" }, { status: 400 });
652
+ }
653
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/login`, {
654
+ method: "POST",
655
+ headers: headersForPlatformAuth(ctx),
656
+ body: JSON.stringify({ email, password })
657
+ });
658
+ const data = await res.json().catch(() => ({}));
659
+ if (!res.ok) {
660
+ return NextResponse.json(data, { status: res.status });
661
+ }
662
+ if (isTwoFactorLoginResponse(data)) {
663
+ return NextResponse.json(data);
664
+ }
665
+ if (!isTokenResponse(data)) {
666
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
667
+ }
668
+ const response = NextResponse.json({ success: true, user: data.user });
669
+ setAuthCookiesMiddleware(response, ctx.namespace, data);
670
+ return response;
671
+ }
672
+ async function handleOAuthProviders(ctx) {
673
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/oauth-providers`, {
674
+ method: "GET",
675
+ headers: headersForPlatformAuth(ctx)
676
+ });
677
+ const data = await res.json().catch(() => ({}));
678
+ return NextResponse.json(data, { status: res.status });
679
+ }
680
+ async function handleOAuthAuthorize(request, ctx) {
681
+ if (request.method !== "GET" && request.method !== "POST") {
682
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
683
+ }
684
+ const body = request.method === "POST" ? await parseJsonObject(request) : null;
685
+ const provider = typeof body?.provider === "string" ? body.provider : request.nextUrl.searchParams.get("provider");
686
+ const rawRedirectTo = typeof body?.redirectTo === "string" ? body.redirectTo : request.nextUrl.searchParams.get("redirect_to") ?? request.nextUrl.searchParams.get("callbackUrl");
687
+ if (!provider) {
688
+ return NextResponse.json({ error: "provider is required" }, { status: 400 });
689
+ }
690
+ const redirectTo = resolveSafeRelativeRedirectPath(rawRedirectTo, ctx.config.afterSignInUrl);
691
+ const redirectUri = new URL(`${ctx.config.authPrefix}/callback`, request.url);
692
+ redirectUri.searchParams.set("redirect_to", redirectTo);
693
+ const verifier = randomBase64Url(32);
694
+ const challenge = await sha256Base64Url(verifier);
695
+ const scopes = Array.isArray(body?.scopes) ? body.scopes.filter((scope) => typeof scope === "string") : void 0;
696
+ const res = await fetch(`${ctx.platformUrl}/v1/oauth/authorize`, {
697
+ method: "POST",
698
+ headers: headersForPlatformAuth(ctx),
699
+ body: JSON.stringify({
700
+ provider,
701
+ redirect_uri: redirectUri.toString(),
702
+ code_challenge: challenge,
703
+ code_challenge_method: "S256",
704
+ ...scopes && scopes.length > 0 ? { scopes } : {}
705
+ })
706
+ });
707
+ const data = await res.json().catch(() => ({}));
708
+ if (!res.ok || !isOAuthAuthorizeResponse(data)) {
709
+ return NextResponse.json(res.ok ? { error: "invalid_response" } : data, {
710
+ status: res.ok ? 502 : res.status
711
+ });
712
+ }
713
+ const response = request.method === "GET" ? NextResponse.redirect(data.authorization_url) : NextResponse.json({
714
+ authorization_url: data.authorization_url,
715
+ authorizationUrl: data.authorization_url
716
+ });
717
+ setOAuthPkceCookie(response, ctx, { verifier, createdAt: Date.now() });
718
+ return response;
719
+ }
720
+ async function handlePasskeyOptions(request, ctx) {
721
+ if (request.method !== "POST") {
722
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
723
+ }
724
+ const body = await parseJsonObject(request);
725
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/options`, {
726
+ method: "POST",
727
+ headers: headersForPlatformAuth(ctx),
728
+ body: JSON.stringify(body ?? {})
729
+ });
730
+ const data = await res.json().catch(() => ({}));
731
+ return NextResponse.json(data, { status: res.status });
732
+ }
733
+ async function handlePasskeyAuthenticate(request, ctx) {
734
+ if (request.method !== "POST") {
735
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
736
+ }
737
+ const body = await parseJsonObject(request);
738
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/passkey/authenticate`, {
739
+ method: "POST",
740
+ headers: headersForPlatformAuth(ctx),
741
+ body: JSON.stringify(body ?? {})
742
+ });
743
+ const data = await res.json().catch(() => ({}));
744
+ if (!res.ok) {
745
+ return NextResponse.json(data, { status: res.status });
746
+ }
747
+ if (!isTokenResponse(data)) {
748
+ return NextResponse.json({ error: "invalid_response" }, { status: 502 });
749
+ }
750
+ const response = NextResponse.json({ success: true, user: data.user });
751
+ setAuthCookiesMiddleware(response, ctx.namespace, data);
752
+ return response;
753
+ }
754
+ async function handleForgotPassword(request, ctx) {
755
+ if (request.method !== "POST") {
756
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
757
+ }
758
+ const body = await parseJsonObject(request);
759
+ const email = typeof body?.email === "string" ? body.email : null;
760
+ const rawRedirect = typeof body?.redirectUrl === "string" ? body.redirectUrl : typeof body?.redirectTo === "string" ? body.redirectTo : null;
761
+ const redirectUrl = resolveSameOriginUrl(request, rawRedirect, "/reset-password");
762
+ if (!email) {
763
+ return NextResponse.json({ error: "email is required" }, { status: 400 });
764
+ }
765
+ if (!redirectUrl) {
766
+ return NextResponse.json({ error: "invalid_redirect_url" }, { status: 400 });
767
+ }
768
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/forgot-password`, {
769
+ method: "POST",
770
+ headers: headersForPlatformAuth(ctx),
771
+ body: JSON.stringify({ email, redirectUrl })
772
+ });
773
+ const data = await res.json().catch(() => ({}));
774
+ return NextResponse.json(data, { status: res.status });
775
+ }
776
+ async function handleResetPassword(request, ctx) {
777
+ if (request.method !== "POST") {
778
+ return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
779
+ }
780
+ const body = await parseJsonObject(request);
781
+ const token = typeof body?.token === "string" ? body.token : null;
782
+ const password = typeof body?.password === "string" ? body.password : typeof body?.newPassword === "string" ? body.newPassword : null;
783
+ if (!token || !password) {
784
+ return NextResponse.json({ error: "token and password are required" }, { status: 400 });
785
+ }
786
+ const res = await fetch(`${ctx.platformUrl}/v1/auth/reset-password`, {
787
+ method: "POST",
788
+ headers: headersForPlatformAuth(ctx),
789
+ body: JSON.stringify({ token, password })
790
+ });
791
+ const data = await res.json().catch(() => ({}));
792
+ return NextResponse.json(data, { status: res.status });
793
+ }
504
794
  async function handleCallback(request, ctx) {
505
795
  const { searchParams } = request.nextUrl;
506
796
  const code = searchParams.get("code");
@@ -520,30 +810,37 @@ async function handleCallback(request, ctx) {
520
810
  return NextResponse.redirect(url);
521
811
  }
522
812
  try {
813
+ const codeVerifier = readOAuthPkceVerifier(request, ctx);
523
814
  const res = await fetch(`${ctx.platformUrl}/v1/auth/token`, {
524
815
  method: "POST",
525
816
  headers: { "Content-Type": "application/json" },
526
817
  body: JSON.stringify({
527
818
  grant_type: "authorization_code",
528
819
  code,
529
- client_secret: ctx.secretKey
820
+ client_secret: ctx.secretKey,
821
+ ...codeVerifier ? { code_verifier: codeVerifier } : {}
530
822
  })
531
823
  });
532
824
  if (!res.ok) {
533
825
  const data2 = await res.json().catch(() => ({}));
534
826
  const url = new URL(ctx.config.signInUrl, request.url);
535
827
  url.searchParams.set("error", data2.error || "token_exchange_failed");
536
- return NextResponse.redirect(url);
828
+ const response2 = NextResponse.redirect(url);
829
+ clearOAuthPkceCookie(response2, ctx);
830
+ return response2;
537
831
  }
538
832
  const data = await res.json();
539
833
  if (!isTokenResponse(data)) {
540
834
  const url = new URL(ctx.config.signInUrl, request.url);
541
835
  url.searchParams.set("error", "invalid_response");
542
- return NextResponse.redirect(url);
836
+ const response2 = NextResponse.redirect(url);
837
+ clearOAuthPkceCookie(response2, ctx);
838
+ return response2;
543
839
  }
544
840
  const successUrl = new URL(redirectTo, request.url);
545
841
  const response = NextResponse.redirect(successUrl);
546
842
  setAuthCookiesMiddleware(response, ctx.namespace, data);
843
+ clearOAuthPkceCookie(response, ctx);
547
844
  ctx.log("Callback success", { redirectTo });
548
845
  return response;
549
846
  } catch (err) {
@@ -832,6 +1129,30 @@ async function handleSwitchOrg(request, ctx) {
832
1129
  return NextResponse.json({ error: "internal_error" }, { status: 500 });
833
1130
  }
834
1131
  }
1132
+ async function handleBaasProxy(request, ctx, sessionToken) {
1133
+ const prefix = ctx.config.baasPrefix;
1134
+ if (!prefix) return NextResponse.json({ error: "Not found" }, { status: 404 });
1135
+ const upstreamPath = stripRoutePrefix(request.nextUrl.pathname, prefix);
1136
+ if (!upstreamPath || !isAllowedBaasProxyPath(upstreamPath)) {
1137
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
1138
+ }
1139
+ if (isAuthenticatedBaasProxyPath(upstreamPath) && !sessionToken) {
1140
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
1141
+ }
1142
+ const upstreamUrl = new URL(`${ctx.platformUrl}/v1${upstreamPath}`);
1143
+ upstreamUrl.search = request.nextUrl.search;
1144
+ const method = request.method.toUpperCase();
1145
+ const body = BODYLESS_METHODS.has(method) ? void 0 : await request.arrayBuffer();
1146
+ const res = await fetch(upstreamUrl, {
1147
+ method,
1148
+ headers: buildUpstreamProxyHeaders(request, ctx, sessionToken),
1149
+ body
1150
+ });
1151
+ return new NextResponse(res.body, {
1152
+ status: res.status,
1153
+ headers: copyResponseHeaders(res.headers)
1154
+ });
1155
+ }
835
1156
  async function refreshTokens(refreshToken, ctx) {
836
1157
  ctx.log("Refreshing tokens");
837
1158
  try {
@@ -896,6 +1217,7 @@ function createSylphxMiddleware(userConfig = {}) {
896
1217
  afterSignOutUrl: userConfig.afterSignOutUrl ?? "/",
897
1218
  afterSignInUrl: userConfig.afterSignInUrl ?? "/dashboard",
898
1219
  authPrefix: userConfig.authPrefix ?? "/auth",
1220
+ baasPrefix: userConfig.baasPrefix === false ? null : normalizeRoutePrefix(userConfig.baasPrefix ?? DEFAULT_BAAS_PREFIX),
899
1221
  debug: userConfig.debug ?? false,
900
1222
  orgRequired: userConfig.orgRequired ?? false,
901
1223
  orgSelectionUrl: userConfig.orgSelectionUrl ?? "/select-organization",
@@ -911,7 +1233,8 @@ function createSylphxMiddleware(userConfig = {}) {
911
1233
  ...config.publicRoutes,
912
1234
  config.signInUrl,
913
1235
  "/signup",
914
- `${config.authPrefix}/*`
1236
+ `${config.authPrefix}/*`,
1237
+ ...config.baasPrefix ? [`${config.baasPrefix}/*`] : []
915
1238
  ];
916
1239
  const log = (msg, data) => {
917
1240
  if (config.debug) {
@@ -947,6 +1270,27 @@ function createSylphxMiddleware(userConfig = {}) {
947
1270
  if (pathname === `${config.authPrefix}/callback`) {
948
1271
  return handleCallback(request, ctx);
949
1272
  }
1273
+ if (pathname === `${config.authPrefix}/login`) {
1274
+ return handleLogin(request, ctx);
1275
+ }
1276
+ if (pathname === `${config.authPrefix}/oauth-providers`) {
1277
+ return handleOAuthProviders(ctx);
1278
+ }
1279
+ if (pathname === `${config.authPrefix}/oauth/authorize`) {
1280
+ return handleOAuthAuthorize(request, ctx);
1281
+ }
1282
+ if (pathname === `${config.authPrefix}/passkey/options`) {
1283
+ return handlePasskeyOptions(request, ctx);
1284
+ }
1285
+ if (pathname === `${config.authPrefix}/passkey/authenticate`) {
1286
+ return handlePasskeyAuthenticate(request, ctx);
1287
+ }
1288
+ if (pathname === `${config.authPrefix}/forgot-password`) {
1289
+ return handleForgotPassword(request, ctx);
1290
+ }
1291
+ if (pathname === `${config.authPrefix}/reset-password`) {
1292
+ return handleResetPassword(request, ctx);
1293
+ }
950
1294
  if (pathname === `${config.authPrefix}/signout`) {
951
1295
  return handleSignOut(request, ctx);
952
1296
  }
@@ -984,6 +1328,9 @@ function createSylphxMiddleware(userConfig = {}) {
984
1328
  activeSessionToken = null;
985
1329
  }
986
1330
  }
1331
+ if (config.baasPrefix && stripRoutePrefix(pathname, config.baasPrefix)) {
1332
+ return handleBaasProxy(request, ctx, activeSessionToken);
1333
+ }
987
1334
  const isPublic = matchesAny(pathname, publicRoutes);
988
1335
  if (!isPublic && !isAuthenticated) {
989
1336
  log("Redirecting to sign-in");