@snokam/auth 3.7.1 → 3.7.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.7.2](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.2) (2026-10-06)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **auth:** ending a persona returns to where it started; no picker loop from sign-in ([#2708](https://github.com/snokam/components/issues/2708)) ([e609377](https://github.com/snokam/components/commit/e6093776f8a5458b611f622f09611145b7cbc461))
11
+ - **mcp:** return iss on the sign-in callback so Codex and Cursor accept it ([#2707](https://github.com/snokam/components/issues/2707)) ([b55887e](https://github.com/snokam/components/commit/b55887e62b9e4b84fbd4bed7fbd4b5e588b534a4))
12
+
6
13
  ## [3.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
7
14
 
8
15
  **Note:** Version bump only for package @snokam/auth
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@snokam/auth",
3
3
  "description": "Snøkam auth components",
4
- "version": "3.7.1",
4
+ "version": "3.7.2",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -27,10 +27,10 @@
27
27
  "next-auth": "4.24.15",
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "@snokam/identity": "1.1.0",
30
- "@snokam/platform-client": "3.6.0",
30
+ "@snokam/platform-client": "3.6.1",
31
31
  "@snokam/react-query": "1.1.0",
32
- "@snokam/sanity-page-content": "4.4.2",
33
- "@snokam/tenant": "2.1.13",
32
+ "@snokam/sanity-page-content": "4.5.0",
33
+ "@snokam/tenant": "2.1.14",
34
34
  "@snokam/ui": "4.0.0"
35
35
  },
36
36
  "peerDependencies": {
@@ -13,6 +13,7 @@ export interface ConsentClaims extends AuthorizationParams {
13
13
  state: string;
14
14
  clientName: string;
15
15
  businessName: string;
16
+ issuer: string;
16
17
  email: string;
17
18
  nonce: string;
18
19
  }
@@ -15,6 +15,7 @@ import {
15
15
  import type { Auth } from "../types";
16
16
  import {
17
17
  type AuthorizationParams,
18
+ type ConsentClaims,
18
19
  CONSENT_TTL_SECONDS,
19
20
  readAppSignInConsent,
20
21
  sealConsent,
@@ -133,15 +134,15 @@ function sameOrigin(request: Request): boolean {
133
134
 
134
135
  function clientRedirect(
135
136
  request: Request,
136
- redirectUri: string,
137
- state: string,
137
+ consent: ConsentClaims,
138
138
  result: Record<string, string>
139
139
  ): Response {
140
- const target = new URL(redirectUri);
140
+ const target = new URL(consent.redirectUri);
141
141
  for (const [key, value] of Object.entries(result)) {
142
142
  target.searchParams.set(key, value);
143
143
  }
144
- if (state) target.searchParams.set("state", state);
144
+ if (consent.state) target.searchParams.set("state", consent.state);
145
+ if (consent.issuer) target.searchParams.set("iss", consent.issuer);
145
146
  return new Response(null, {
146
147
  status: 303,
147
148
  headers: {
@@ -180,6 +181,7 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
180
181
  ...params,
181
182
  clientName: preview.value.clientName ?? "",
182
183
  businessName: preview.value.businessName ?? "",
184
+ issuer: preview.value.issuer ?? "",
183
185
  business: new URL(params.resource).hostname,
184
186
  state: query.get("state") ?? "",
185
187
  email: signer.email,
@@ -219,16 +221,13 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
219
221
  if (!signer || signer.email !== consent.email) return invalidRequest();
220
222
 
221
223
  if (form.get("decision") !== "approve") {
222
- return clientRedirect(request, consent.redirectUri, consent.state, {
223
- error: ACCESS_DENIED,
224
- });
224
+ return clientRedirect(request, consent, { error: ACCESS_DENIED });
225
225
  }
226
226
 
227
227
  const approved = await approveAuthorization(consent, signer.accessToken);
228
228
  return clientRedirect(
229
229
  request,
230
- consent.redirectUri,
231
- consent.state,
230
+ consent,
232
231
  "error" in approved
233
232
  ? { error: approved.error }
234
233
  : { code: approved.value.code ?? "" }
package/src/hooks.ts CHANGED
@@ -6,6 +6,7 @@ import { useSession } from "./session/useSession";
6
6
  import {
7
7
  IMPERSONATION_REQUIRED_ERROR,
8
8
  impersonationPickerUrl,
9
+ isSignInPage,
9
10
  } from "./impersonation/required";
10
11
 
11
12
  class ConsentRequiredError extends Error {}
@@ -17,7 +18,7 @@ class ImpersonationRequiredError extends Error {
17
18
  }
18
19
 
19
20
  function sendToPicker(error: ImpersonationRequiredError): void {
20
- if (!error.platformUrl) return;
21
+ if (!error.platformUrl || isSignInPage(window.location.pathname)) return;
21
22
  window.location.assign(
22
23
  impersonationPickerUrl(error.platformUrl, window.location.href)
23
24
  );
@@ -12,6 +12,7 @@ import {
12
12
  import { IMP_ACTIVE_COOKIE, type ImpersonationActive } from "./cookies";
13
13
  import { clearAuthTokenCache } from "../hooks";
14
14
  import { useSessionRenewal } from "./renewal";
15
+ import { useOptionalConfig } from "@snokam/tenant";
15
16
  import { isAllowedRedirect, platformBaseDomain } from "./redirects";
16
17
 
17
18
  export interface StartImpersonationInput {
@@ -38,11 +39,14 @@ const ImpersonationContext = createContext<ImpersonationContextValue | null>(
38
39
  null
39
40
  );
40
41
 
41
- function isSafeReturnTo(returnTo: string): boolean {
42
- if (typeof window === "undefined") return false;
42
+ function isSafeReturnTo(
43
+ returnTo: string,
44
+ apiBaseUrl: string | undefined
45
+ ): boolean {
46
+ if (typeof window === "undefined" || !apiBaseUrl) return false;
43
47
  return isAllowedRedirect(returnTo, {
44
48
  origin: window.location.origin,
45
- platformDomain: platformBaseDomain(),
49
+ platformDomain: platformBaseDomain(apiBaseUrl),
46
50
  });
47
51
  }
48
52
 
@@ -64,6 +68,7 @@ function readActiveCookie(): ImpersonationActive | null {
64
68
  }
65
69
 
66
70
  export function ImpersonationProvider({ children }: { children: ReactNode }) {
71
+ const apiBaseUrl = useOptionalConfig()?.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
67
72
  const [active, setActive] = useState<ImpersonationActive | null>(null);
68
73
  const [starting, setStarting] = useState(false);
69
74
  const [exiting, setExiting] = useState(false);
@@ -116,7 +121,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
116
121
  clearAuthTokenCache();
117
122
  const returnTo = active?.returnTo;
118
123
  setActive(null);
119
- if (returnTo && isSafeReturnTo(returnTo)) {
124
+ if (returnTo && isSafeReturnTo(returnTo, apiBaseUrl)) {
120
125
  window.location.assign(returnTo);
121
126
  } else {
122
127
  window.location.reload();
@@ -124,7 +129,7 @@ export function ImpersonationProvider({ children }: { children: ReactNode }) {
124
129
  } finally {
125
130
  setExiting(false);
126
131
  }
127
- }, [active]);
132
+ }, [active, apiBaseUrl]);
128
133
 
129
134
  const value = useMemo<ImpersonationContextValue>(
130
135
  () => ({ active, starting, exiting, start, exit }),
@@ -32,7 +32,9 @@ function registrable(host: string): string {
32
32
  function resolveLanding(returnTo: string, origin: string): string {
33
33
  const allowed = isAllowedRedirect(returnTo, {
34
34
  origin,
35
- platformDomain: platformBaseDomain(),
35
+ platformDomain: platformBaseDomain(
36
+ process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL
37
+ ),
36
38
  });
37
39
  return allowed
38
40
  ? new URL(returnTo, origin).toString()
@@ -1,9 +1,8 @@
1
- export function platformBaseDomain(): string {
2
- const api = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL;
3
- if (!api) {
1
+ export function platformBaseDomain(apiBaseUrl: string | undefined): string {
2
+ if (!apiBaseUrl) {
4
3
  throw new Error("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured");
5
4
  }
6
- return new URL(api).hostname.split(".").slice(-2).join(".");
5
+ return new URL(apiBaseUrl).hostname.split(".").slice(-2).join(".");
7
6
  }
8
7
 
9
8
  interface RedirectRules {
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  impersonationPickerUrl,
5
5
  impersonationRequired,
6
+ isSignInPage,
6
7
  renewDelayMs,
7
8
  } from "./required.ts";
8
9
 
@@ -32,6 +33,14 @@ describe("impersonation required", () => {
32
33
  );
33
34
  });
34
35
 
36
+ it("never sends a sign-in page to the picker, at the root or under a base path", () => {
37
+ assert.equal(isSignInPage("/auth/complete"), true);
38
+ assert.equal(isSignInPage("/minside/auth/login"), true);
39
+ assert.equal(isSignInPage("/"), false);
40
+ assert.equal(isSignInPage("/okonomi"), false);
41
+ assert.equal(isSignInPage("/authors"), false);
42
+ });
43
+
35
44
  it("renews two minutes before the token expires, and at once when already late", () => {
36
45
  assert.equal(renewDelayMs(1_000, 0), 880_000);
37
46
  assert.equal(renewDelayMs(1_000, 999_000), 0);
@@ -12,6 +12,10 @@ export function impersonationRequired(
12
12
  return environment === TEST_ENVIRONMENT;
13
13
  }
14
14
 
15
+ export function isSignInPage(pathname: string): boolean {
16
+ return /(^|\/)auth\//.test(pathname);
17
+ }
18
+
15
19
  export function impersonationPickerUrl(
16
20
  platformBaseUrl: string,
17
21
  returnTo: string