@sylphx/sdk 0.10.4 → 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
  *
@@ -453,6 +453,8 @@ 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;
456
458
  function isTokenResponse(data) {
457
459
  return typeof data === "object" && data !== null && "accessToken" in data && "refreshToken" in data && "user" in data && typeof data.accessToken === "string" && typeof data.refreshToken === "string";
458
460
  }
@@ -501,6 +503,151 @@ function resolveSafeRelativeRedirectPath(value, fallback) {
501
503
  if (isSafeRelativeRedirectPath(fallback)) return fallback;
502
504
  return "/";
503
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
+ }
504
651
  async function handleCallback(request, ctx) {
505
652
  const { searchParams } = request.nextUrl;
506
653
  const code = searchParams.get("code");
@@ -520,30 +667,37 @@ async function handleCallback(request, ctx) {
520
667
  return NextResponse.redirect(url);
521
668
  }
522
669
  try {
670
+ const codeVerifier = readOAuthPkceVerifier(request, ctx);
523
671
  const res = await fetch(`${ctx.platformUrl}/v1/auth/token`, {
524
672
  method: "POST",
525
673
  headers: { "Content-Type": "application/json" },
526
674
  body: JSON.stringify({
527
675
  grant_type: "authorization_code",
528
676
  code,
529
- client_secret: ctx.secretKey
677
+ client_secret: ctx.secretKey,
678
+ ...codeVerifier ? { code_verifier: codeVerifier } : {}
530
679
  })
531
680
  });
532
681
  if (!res.ok) {
533
682
  const data2 = await res.json().catch(() => ({}));
534
683
  const url = new URL(ctx.config.signInUrl, request.url);
535
684
  url.searchParams.set("error", data2.error || "token_exchange_failed");
536
- return NextResponse.redirect(url);
685
+ const response2 = NextResponse.redirect(url);
686
+ clearOAuthPkceCookie(response2, ctx);
687
+ return response2;
537
688
  }
538
689
  const data = await res.json();
539
690
  if (!isTokenResponse(data)) {
540
691
  const url = new URL(ctx.config.signInUrl, request.url);
541
692
  url.searchParams.set("error", "invalid_response");
542
- return NextResponse.redirect(url);
693
+ const response2 = NextResponse.redirect(url);
694
+ clearOAuthPkceCookie(response2, ctx);
695
+ return response2;
543
696
  }
544
697
  const successUrl = new URL(redirectTo, request.url);
545
698
  const response = NextResponse.redirect(successUrl);
546
699
  setAuthCookiesMiddleware(response, ctx.namespace, data);
700
+ clearOAuthPkceCookie(response, ctx);
547
701
  ctx.log("Callback success", { redirectTo });
548
702
  return response;
549
703
  } catch (err) {
@@ -947,6 +1101,15 @@ function createSylphxMiddleware(userConfig = {}) {
947
1101
  if (pathname === `${config.authPrefix}/callback`) {
948
1102
  return handleCallback(request, ctx);
949
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
+ }
950
1113
  if (pathname === `${config.authPrefix}/signout`) {
951
1114
  return handleSignOut(request, ctx);
952
1115
  }