@snokam/auth 3.5.0 → 3.6.0

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,12 @@
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.6.0](https://github.com/snokam/components/compare/@snokam/auth@3.5.0...@snokam/auth@3.6.0) (2026-10-06)
7
+
8
+ ### Features
9
+
10
+ - **snosky:** no prices, no terms page, unlinked /status in Snøkam look, better persona flow ([#2681](https://github.com/snokam/components/issues/2681)) ([b6dd5af](https://github.com/snokam/components/commit/b6dd5af68258c31f717888372fc7258d233c9e2e))
11
+
6
12
  # [3.5.0](https://github.com/snokam/components/compare/@snokam/auth@3.4.0...@snokam/auth@3.5.0) (2026-10-06)
7
13
 
8
14
  ### Features
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.5.0",
4
+ "version": "3.6.0",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -28,10 +28,10 @@
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "@snokam/identity": "1.1.0",
30
30
  "@snokam/platform-client": "3.6.0",
31
- "@snokam/tenant": "2.1.13",
32
- "@snokam/ui": "4.0.0",
33
31
  "@snokam/react-query": "1.1.0",
34
- "@snokam/sanity-page-content": "4.4.2"
32
+ "@snokam/sanity-page-content": "4.4.2",
33
+ "@snokam/tenant": "2.1.13",
34
+ "@snokam/ui": "4.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "next": "16.3.3",
@@ -6,10 +6,8 @@ import {
6
6
  requestOrigin,
7
7
  } from "../session";
8
8
  import { resolveAccessToken } from "../route-handler/token";
9
- import {
10
- readImpersonationActive,
11
- readImpersonationSession,
12
- } from "../impersonation/cookies";
9
+ import { readImpersonationSession } from "../impersonation/cookies";
10
+ import { tokenClaims } from "../impersonation/claims";
13
11
  import {
14
12
  impersonationPickerUrl,
15
13
  impersonationRequired,
@@ -58,13 +56,18 @@ interface Signer {
58
56
  accessToken: string;
59
57
  }
60
58
 
61
- function personaSigner(request: Request): Signer | null {
59
+ function sameEmail(a: string | undefined, b: string): boolean {
60
+ return !!a && a.toLowerCase() === b.toLowerCase();
61
+ }
62
+
63
+ function personaSigner(request: Request, operator: string): Signer | null {
62
64
  const session = readImpersonationSession(request);
63
- const active = readImpersonationActive(request);
64
- if (!session || !active || session.expiresOn * 1000 <= Date.now()) {
65
- return null;
66
- }
67
- return { email: active.targetEmail, accessToken: session.accessToken };
65
+ if (!session || session.expiresOn * 1000 <= Date.now()) return null;
66
+
67
+ const claims = tokenClaims(session.accessToken);
68
+ if (!claims.email || !sameEmail(claims.act, operator)) return null;
69
+
70
+ return { email: claims.email, accessToken: session.accessToken };
68
71
  }
69
72
 
70
73
  async function ownSigner(
@@ -82,7 +85,7 @@ function signerFor(
82
85
  email: string
83
86
  ): Promise<Signer | null> {
84
87
  if (impersonationRequired(authConfig.environment)) {
85
- return Promise.resolve(personaSigner(request));
88
+ return Promise.resolve(personaSigner(request, email));
86
89
  }
87
90
  return ownSigner(authConfig, request, email);
88
91
  }
@@ -175,7 +178,8 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
175
178
  const ticket = sealConsent(
176
179
  {
177
180
  ...params,
178
- ...preview.value,
181
+ clientName: preview.value.clientName ?? "",
182
+ businessName: preview.value.businessName ?? "",
179
183
  business: new URL(params.resource).hostname,
180
184
  state: query.get("state") ?? "",
181
185
  email: signer.email,
@@ -227,7 +231,7 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
227
231
  consent.state,
228
232
  "error" in approved
229
233
  ? { error: approved.error }
230
- : { code: approved.value.code }
234
+ : { code: approved.value.code ?? "" }
231
235
  );
232
236
  };
233
237
  }
@@ -1,11 +1,13 @@
1
1
  import { getManagedBearer } from "@snokam/identity";
2
+ import {
3
+ Configuration,
4
+ PlatformClient,
5
+ ResponseError,
6
+ type AuthorizationPreview,
7
+ type IssuedAuthorizationCode,
8
+ } from "@snokam/platform-client";
2
9
  import type { AuthorizationParams } from "./consent";
3
10
 
4
- export interface AuthorizationPreview {
5
- clientName: string;
6
- businessName: string;
7
- }
8
-
9
11
  type Outcome<T> = { value: T } | { error: string };
10
12
 
11
13
  const UNAVAILABLE = "temporarily_unavailable";
@@ -17,36 +19,45 @@ function apiBase(): string {
17
19
  );
18
20
  }
19
21
 
20
- async function post<T>(
21
- path: string,
22
- params: AuthorizationParams,
23
- userToken: string
22
+ async function asLoginSite<T>(
23
+ call: (client: PlatformClient) => Promise<T>
24
24
  ): Promise<Outcome<T>> {
25
25
  const loginSite = await getManagedBearer();
26
26
  if (!loginSite) return { error: UNAVAILABLE };
27
27
 
28
- const response = await fetch(`${apiBase()}${path}`, {
29
- method: "POST",
30
- headers: { "Content-Type": "application/json", Authorization: loginSite },
31
- body: JSON.stringify({ ...params, userToken }),
32
- cache: "no-store",
33
- });
34
- const body = await response.json().catch(() => ({}));
35
- return response.ok
36
- ? { value: body as T }
37
- : { error: String(body.error ?? "invalid_request") };
28
+ const client = new PlatformClient(
29
+ new Configuration({
30
+ basePath: apiBase(),
31
+ accessToken: async () => loginSite,
32
+ })
33
+ );
34
+ try {
35
+ return { value: await call(client) };
36
+ } catch (error) {
37
+ if (!(error instanceof ResponseError)) throw error;
38
+ const body = await error.response.json().catch(() => ({}));
39
+ return { error: String(body.error ?? "invalid_request") };
40
+ }
38
41
  }
39
42
 
40
43
  export function previewAuthorization(
41
44
  params: AuthorizationParams,
42
45
  userToken: string
43
46
  ): Promise<Outcome<AuthorizationPreview>> {
44
- return post("/v1.0/protected/oauth/authorization/preview", params, userToken);
47
+ return asLoginSite((client) =>
48
+ client.previewOAuthAuthorization({
49
+ authorizationRequest: { ...params, userToken },
50
+ })
51
+ );
45
52
  }
46
53
 
47
54
  export function approveAuthorization(
48
55
  params: AuthorizationParams,
49
56
  userToken: string
50
- ): Promise<Outcome<{ code: string }>> {
51
- return post("/v1.0/protected/oauth/authorization", params, userToken);
57
+ ): Promise<Outcome<IssuedAuthorizationCode>> {
58
+ return asLoginSite((client) =>
59
+ client.approveOAuthAuthorization({
60
+ authorizationRequest: { ...params, userToken },
61
+ })
62
+ );
52
63
  }
@@ -0,0 +1,16 @@
1
+ export interface TokenClaims {
2
+ biz?: string;
3
+ email?: string;
4
+ act?: string;
5
+ }
6
+
7
+ export function tokenClaims(accessToken: string): TokenClaims {
8
+ try {
9
+ const payload = accessToken.split(".")[1] ?? "";
10
+ return JSON.parse(
11
+ Buffer.from(payload, "base64url").toString("utf8")
12
+ ) as TokenClaims;
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
@@ -7,7 +7,12 @@ import {
7
7
  } from "./cookies";
8
8
  import { sealImpersonationTicket, openImpersonationTicket } from "./ticket";
9
9
  import { requestOrigin, resolveCookieDomain } from "../session";
10
- import { platformBase, platformErrorResponse, requestHost } from "./request";
10
+ import {
11
+ platformCall,
12
+ platformClient,
13
+ platformNotConfigured,
14
+ requestHost,
15
+ } from "./request";
11
16
 
12
17
  function registrable(host: string): string {
13
18
  return host.split(":")[0].toLowerCase().split(".").slice(-2).join(".");
@@ -64,17 +69,12 @@ function handsOffToTarget(request: Request, target: string): boolean {
64
69
  */
65
70
  export function createImpersonationStartHandler(authConfig: Auth.Config) {
66
71
  return async function POST(request: Request) {
67
- const base = platformBase();
68
- if (!base) {
69
- return new Response(
70
- "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
71
- { status: 500 }
72
- );
73
- }
74
-
75
72
  const caller = await resolveAccessToken(authConfig, request);
76
73
  if ("error" in caller) return caller.error;
77
74
 
75
+ const client = platformClient(caller.accessToken);
76
+ if (!client) return platformNotConfigured();
77
+
78
78
  let body: {
79
79
  targetEmail?: string;
80
80
  targetName?: string;
@@ -91,23 +91,26 @@ export function createImpersonationStartHandler(authConfig: Auth.Config) {
91
91
  return new Response("Invalid request body", { status: 400 });
92
92
  }
93
93
 
94
- const minted = await fetch(`${base}/v1.0/protected/impersonate`, {
95
- method: "POST",
96
- headers: {
97
- Authorization: `Bearer ${caller.accessToken}`,
98
- "Content-Type": "application/json",
99
- },
100
- body: JSON.stringify({
101
- targetEmail: body.targetEmail,
102
- targetName: body.targetName,
103
- reason: body.reason,
104
- }),
105
- });
106
-
107
- if (!minted.ok) return platformErrorResponse(minted);
108
-
109
- const result = await minted.json();
110
- result.targetImage = body.targetImage ?? null;
94
+ const minted = await platformCall(
95
+ client.startImpersonation({
96
+ startImpersonationRequest: {
97
+ targetEmail: body.targetEmail,
98
+ targetName: body.targetName,
99
+ reason: body.reason,
100
+ },
101
+ })
102
+ );
103
+ if ("error" in minted) return minted.error;
104
+
105
+ const result = {
106
+ accessToken: minted.value.accessToken ?? "",
107
+ expiresOn: minted.value.expiresOn ?? 0,
108
+ tokenId: minted.value.tokenId ?? "",
109
+ targetEmail: minted.value.targetEmail ?? body.targetEmail ?? "",
110
+ targetName: minted.value.targetName,
111
+ targetImage: body.targetImage ?? null,
112
+ finance: minted.value.finance ?? false,
113
+ };
111
114
  const summary = {
112
115
  targetEmail: result.targetEmail,
113
116
  targetName: result.targetName,
@@ -200,19 +203,14 @@ export function createImpersonationAdoptHandler(authConfig: Auth.Config) {
200
203
  export function createImpersonationEndHandler(authConfig: Auth.Config) {
201
204
  return async function POST(request: Request) {
202
205
  const session = readImpersonationSession(request);
203
- const base = platformBase();
204
206
 
205
- if (session?.tokenId && base) {
207
+ if (session?.tokenId) {
206
208
  const caller = await resolveAccessToken(authConfig, request);
207
- if (!("error" in caller)) {
208
- await fetch(
209
- `${base}/v1.0/protected/impersonate/${encodeURIComponent(session.tokenId)}/end`,
210
- {
211
- method: "POST",
212
- headers: { Authorization: `Bearer ${caller.accessToken}` },
213
- }
214
- ).catch(() => undefined);
215
- }
209
+ const client =
210
+ "error" in caller ? null : platformClient(caller.accessToken);
211
+ await client
212
+ ?.endImpersonation({ tokenId: session.tokenId })
213
+ .catch(() => undefined);
216
214
  }
217
215
 
218
216
  const out = Response.json({ ok: true });
@@ -1,18 +1,25 @@
1
+ import type { ProtectedEmployee } from "@snokam/platform-client";
1
2
  import { Auth } from "../types";
2
3
  import { resolveAccessToken } from "../route-handler/token";
3
- import { platformBase, platformErrorResponse } from "./request";
4
+ import { tokenClaims } from "./claims";
5
+ import { platformCall, platformClient, platformNotConfigured } from "./request";
4
6
  import { impersonationRequired } from "./required";
5
7
 
6
- function businessOf(accessToken: string): string | null {
7
- try {
8
- const payload = accessToken.split(".")[1] ?? "";
9
- const claims = JSON.parse(
10
- Buffer.from(payload, "base64url").toString("utf8")
11
- ) as { biz?: string };
12
- return claims.biz ?? null;
13
- } catch {
14
- return null;
15
- }
8
+ function persona(employee: ProtectedEmployee) {
9
+ return {
10
+ name: employee.name,
11
+ email: employee.email,
12
+ position: employee.position,
13
+ about: employee.about,
14
+ active: employee.active,
15
+ isBot: employee.isBot,
16
+ image: { asset: { url: employee.image?.asset?.url } },
17
+ scopeGrants: employee.scopeGrants,
18
+ groups: (employee.groups ?? []).map((group) => ({
19
+ name: group.name,
20
+ scopeGrants: group.scopeGrants,
21
+ })),
22
+ };
16
23
  }
17
24
 
18
25
  export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
@@ -21,32 +28,29 @@ export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
21
28
  return new Response("Not found", { status: 404 });
22
29
  }
23
30
 
24
- const base = platformBase();
25
- if (!base) {
26
- return new Response(
27
- "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
28
- { status: 500 }
29
- );
30
- }
31
-
32
31
  const caller = await resolveAccessToken(authConfig, request);
33
32
  if ("error" in caller) return caller.error;
34
33
 
35
- const businessId = businessOf(caller.accessToken);
34
+ const client = platformClient(caller.accessToken);
35
+ if (!client) return platformNotConfigured();
36
+
37
+ const businessId = tokenClaims(caller.accessToken).biz;
36
38
  if (!businessId) {
37
39
  return new Response("The signed-in account has no business", {
38
40
  status: 409,
39
41
  });
40
42
  }
41
43
 
42
- const employees = await fetch(
43
- `${base}/v1.0/protected/businesses/${encodeURIComponent(businessId)}/employees`,
44
- { headers: { Authorization: `Bearer ${caller.accessToken}` } }
45
- );
46
- if (!employees.ok) return platformErrorResponse(employees);
44
+ const [employees, apps] = await Promise.all([
45
+ platformCall(client.listBusinessEmployees({ id: businessId })),
46
+ platformCall(client.getMeApps()),
47
+ ]);
48
+ if ("error" in employees) return employees.error;
49
+ if ("error" in apps) return apps.error;
47
50
 
48
- return new Response(await employees.text(), {
49
- headers: { "Content-Type": "application/json" },
51
+ return Response.json({
52
+ personas: employees.value.map(persona),
53
+ apps: apps.value,
50
54
  });
51
55
  };
52
56
  }
@@ -1,8 +1,3 @@
1
- import {
2
- Configuration,
3
- PlatformClient,
4
- ResponseError,
5
- } from "@snokam/platform-client";
6
1
  import { Auth } from "../types";
7
2
  import { resolveAccessToken } from "../route-handler/token";
8
3
  import {
@@ -10,7 +5,12 @@ import {
10
5
  readImpersonationActive,
11
6
  readImpersonationSession,
12
7
  } from "./cookies";
13
- import { platformBase, platformErrorResponse, requestHost } from "./request";
8
+ import {
9
+ platformCall,
10
+ platformClient,
11
+ platformNotConfigured,
12
+ requestHost,
13
+ } from "./request";
14
14
 
15
15
  export function createImpersonationRenewHandler(authConfig: Auth.Config) {
16
16
  return async function POST(request: Request) {
@@ -20,56 +20,36 @@ export function createImpersonationRenewHandler(authConfig: Auth.Config) {
20
20
  return new Response("No impersonation session", { status: 404 });
21
21
  }
22
22
 
23
- const basePath = platformBase();
24
- if (!basePath) {
25
- return new Response(
26
- "NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured",
27
- {
28
- status: 500,
29
- }
30
- );
31
- }
32
-
33
23
  const caller = await resolveAccessToken(authConfig, request);
34
24
  if ("error" in caller) return caller.error;
35
25
 
36
- const client = new PlatformClient(
37
- new Configuration({
38
- basePath,
39
- accessToken: async () => caller.accessToken,
40
- })
26
+ const client = platformClient(caller.accessToken);
27
+ if (!client) return platformNotConfigured();
28
+
29
+ const renewal = await platformCall(
30
+ client.renewImpersonation({ tokenId: session.tokenId })
41
31
  );
32
+ if ("error" in renewal) return renewal.error;
42
33
 
43
- try {
44
- const renewed = await client.renewImpersonation({
45
- tokenId: session.tokenId,
46
- });
47
- if (!renewed.accessToken || !renewed.expiresOn) {
48
- return new Response("Incomplete impersonation renewal", {
49
- status: 502,
50
- });
51
- }
34
+ const renewed = renewal.value;
35
+ if (!renewed.accessToken || !renewed.expiresOn) {
36
+ return new Response("Incomplete impersonation renewal", { status: 502 });
37
+ }
52
38
 
53
- const out = Response.json({
54
- tokenId: session.tokenId,
39
+ const out = Response.json({
40
+ tokenId: session.tokenId,
41
+ expiresOn: renewed.expiresOn,
42
+ });
43
+ const cookies = buildImpersonationCookies(
44
+ {
45
+ ...active,
46
+ accessToken: renewed.accessToken,
55
47
  expiresOn: renewed.expiresOn,
56
- });
57
- const cookies = buildImpersonationCookies(
58
- {
59
- ...active,
60
- accessToken: renewed.accessToken,
61
- expiresOn: renewed.expiresOn,
62
- finance: renewed.finance ?? false,
63
- },
64
- requestHost(request)
65
- );
66
- for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
67
- return out;
68
- } catch (error) {
69
- if (error instanceof ResponseError) {
70
- return platformErrorResponse(error.response);
71
- }
72
- throw error;
73
- }
48
+ finance: renewed.finance ?? false,
49
+ },
50
+ requestHost(request)
51
+ );
52
+ for (const cookie of cookies) out.headers.append("Set-Cookie", cookie);
53
+ return out;
74
54
  };
75
55
  }
@@ -1,9 +1,32 @@
1
+ import {
2
+ Configuration,
3
+ PlatformClient,
4
+ ResponseError,
5
+ } from "@snokam/platform-client";
6
+
1
7
  export function platformBase(): string | null {
2
8
  return (
3
9
  process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL?.replace(/\/$/, "") ?? null
4
10
  );
5
11
  }
6
12
 
13
+ export function platformClient(accessToken: string): PlatformClient | null {
14
+ const basePath = platformBase();
15
+ if (!basePath) return null;
16
+ return new PlatformClient(
17
+ new Configuration({
18
+ basePath,
19
+ accessToken: async () => `Bearer ${accessToken}`,
20
+ })
21
+ );
22
+ }
23
+
24
+ export function platformNotConfigured(): Response {
25
+ return new Response("NEXT_PUBLIC_PLATFORM_API_BASE_URL is not configured", {
26
+ status: 500,
27
+ });
28
+ }
29
+
7
30
  export function requestHost(request: Request): string | null {
8
31
  return request.headers.get("x-forwarded-host") ?? request.headers.get("host");
9
32
  }
@@ -24,3 +47,18 @@ export async function platformErrorResponse(
24
47
  status: response.status,
25
48
  });
26
49
  }
50
+
51
+ export type PlatformResult<T> = { value: T } | { error: Response };
52
+
53
+ export async function platformCall<T>(
54
+ call: Promise<T>
55
+ ): Promise<PlatformResult<T>> {
56
+ try {
57
+ return { value: await call };
58
+ } catch (error) {
59
+ if (error instanceof ResponseError) {
60
+ return { error: await platformErrorResponse(error.response) };
61
+ }
62
+ throw error;
63
+ }
64
+ }