@snokam/auth 3.7.1 → 3.7.3
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 +11 -0
- package/package.json +5 -5
- package/src/app-sign-in/consent.ts +1 -0
- package/src/app-sign-in/handlers.ts +8 -9
- package/src/hooks.ts +2 -1
- package/src/impersonation/context.tsx +10 -5
- package/src/impersonation/handlers.ts +3 -1
- package/src/impersonation/redirects.ts +3 -4
- package/src/impersonation/required.test.ts +9 -0
- package/src/impersonation/required.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.3](https://github.com/snokam/components/compare/@snokam/auth@3.7.2...@snokam/auth@3.7.3) (2026-10-06)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @snokam/auth
|
|
9
|
+
|
|
10
|
+
## [3.7.2](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.2) (2026-10-06)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **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))
|
|
15
|
+
- **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))
|
|
16
|
+
|
|
6
17
|
## [3.7.1](https://github.com/snokam/components/compare/@snokam/auth@3.7.0...@snokam/auth@3.7.1) (2026-10-06)
|
|
7
18
|
|
|
8
19
|
**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.
|
|
4
|
+
"version": "3.7.3",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -27,11 +27,11 @@
|
|
|
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.
|
|
30
|
+
"@snokam/platform-client": "3.6.1",
|
|
31
31
|
"@snokam/react-query": "1.1.0",
|
|
32
|
-
"@snokam/
|
|
33
|
-
"@snokam/
|
|
34
|
-
"@snokam/
|
|
32
|
+
"@snokam/tenant": "2.1.14",
|
|
33
|
+
"@snokam/ui": "4.0.0",
|
|
34
|
+
"@snokam/sanity-page-content": "4.6.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"next": "16.3.3",
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
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
|