@snokam/auth 3.7.14 → 3.8.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,22 @@
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.8.0](https://github.com/snokam/components/compare/@snokam/auth@3.7.14...@snokam/auth@3.8.0) (2026-10-14)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **auth:** verify a password once when TOTP enrolment is pending, and say when sign-in is slow ([#2893](https://github.com/snokam/components/issues/2893)) ([d3e5c79](https://github.com/snokam/components/commit/d3e5c79c4c22db49389cddd07e1331dc6d25c381))
11
+
12
+ ### Features
13
+
14
+ - persona mode per business, prs claim, Vis som gate, handoff sealing and CI persona (test identity P1) ([#2894](https://github.com/snokam/components/issues/2894)) ([8ab98a8](https://github.com/snokam/components/commit/8ab98a846ed6a5f962eadca6723c923a55395ce1)), closes [#2890](https://github.com/snokam/components/issues/2890)
15
+
16
+ ## [3.7.15](https://github.com/snokam/components/compare/@snokam/auth@3.7.14...@snokam/auth@3.7.15) (2026-10-14)
17
+
18
+ ### Bug Fixes
19
+
20
+ - **auth:** verify a password once when TOTP enrolment is pending, and say when sign-in is slow ([#2893](https://github.com/snokam/components/issues/2893)) ([d3e5c79](https://github.com/snokam/components/commit/d3e5c79c4c22db49389cddd07e1331dc6d25c381))
21
+
6
22
  ## [3.7.14](https://github.com/snokam/components/compare/@snokam/auth@3.7.13...@snokam/auth@3.7.14) (2026-10-14)
7
23
 
8
24
  **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.14",
4
+ "version": "3.8.0",
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.8.0",
30
+ "@snokam/platform-client": "3.9.0",
31
31
  "@snokam/react-query": "1.1.0",
32
- "@snokam/ui": "4.0.3",
33
- "@snokam/tenant": "2.1.17",
34
- "@snokam/sanity-page-content": "4.11.1"
32
+ "@snokam/sanity-page-content": "4.12.0",
33
+ "@snokam/tenant": "2.1.18",
34
+ "@snokam/ui": "4.1.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "next": "16.3.8",
@@ -33,7 +33,7 @@ export {
33
33
  useImpersonation,
34
34
  } from "../impersonation/context";
35
35
  export type { ImpersonationActive } from "../impersonation/cookies";
36
- export { impersonationRequired } from "../impersonation/required";
36
+ export { FOREIGN_BUSINESS_ERROR } from "../impersonation/required";
37
37
  export { isAllowedRedirect } from "../impersonation/redirects";
38
38
  export { signIn, SessionProvider } from "next-auth/react";
39
39
  export {
@@ -9,8 +9,9 @@ import { resolveAccessToken } from "../route-handler/token";
9
9
  import { readImpersonationSession } from "../impersonation/cookies";
10
10
  import { tokenClaims } from "../impersonation/claims";
11
11
  import {
12
+ chooseToken,
12
13
  impersonationPickerUrl,
13
- impersonationRequired,
14
+ PERSONA_REQUIRED,
14
15
  } from "../impersonation/required";
15
16
  import type { Auth } from "../types";
16
17
  import {
@@ -57,6 +58,8 @@ interface Signer {
57
58
  accessToken: string;
58
59
  }
59
60
 
61
+ type SignerResult = Signer | typeof PERSONA_REQUIRED | null;
62
+
60
63
  function sameEmail(a: string | undefined, b: string): boolean {
61
64
  return !!a && a.toLowerCase() === b.toLowerCase();
62
65
  }
@@ -84,11 +87,12 @@ async function signerFor(
84
87
  authConfig: Auth.Config,
85
88
  request: Request,
86
89
  email: string
87
- ): Promise<Signer | null> {
88
- if (impersonationRequired(authConfig.environment)) {
89
- return personaSigner(request, email);
90
- }
91
- return ownSigner(authConfig, request, email);
90
+ ): Promise<SignerResult> {
91
+ const persona = personaSigner(request, email);
92
+ if (persona) return persona;
93
+
94
+ const own = await ownSigner(authConfig, request, email);
95
+ return chooseToken(null, own, own ? tokenClaims(own.accessToken) : {});
92
96
  }
93
97
 
94
98
  function pickerRedirect(request: Request): Response {
@@ -162,11 +166,8 @@ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
162
166
  if (!signedIn) return loginRedirect(request, params.resource);
163
167
 
164
168
  const signer = await signerFor(authConfig, request, signedIn);
165
- if (!signer) {
166
- return impersonationRequired(authConfig.environment)
167
- ? pickerRedirect(request)
168
- : loginRedirect(request, params.resource);
169
- }
169
+ if (signer === PERSONA_REQUIRED) return pickerRedirect(request);
170
+ if (!signer) return loginRedirect(request, params.resource);
170
171
 
171
172
  const preview = await previewAuthorization(params, signer.accessToken);
172
173
  if ("error" in preview) {
@@ -218,7 +219,13 @@ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
218
219
  if (!signedIn) return invalidRequest();
219
220
 
220
221
  const signer = await signerFor(authConfig, request, signedIn);
221
- if (!signer || signer.email !== consent.email) return invalidRequest();
222
+ if (
223
+ !signer ||
224
+ signer === PERSONA_REQUIRED ||
225
+ signer.email !== consent.email
226
+ ) {
227
+ return invalidRequest();
228
+ }
222
229
 
223
230
  if (form.get("decision") !== "approve") {
224
231
  return clientRedirect(request, consent, { error: ACCESS_DENIED });
package/src/config.ts CHANGED
@@ -5,5 +5,4 @@ export const getAuthConfig = (): Auth.Config => ({
5
5
  tenantId: process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID ?? "",
6
6
  clientSecret: process.env.AZURE_AD_SECRET ?? "",
7
7
  nextAuthSecret: process.env.NEXTAUTH_SECRET ?? "",
8
- environment: process.env.NEXT_PUBLIC_ENVIRONMENT,
9
8
  });
@@ -0,0 +1,26 @@
1
+ import { isLocalHost, resolveTenantByHostStrict } from "@snokam/tenant";
2
+ import { getManagedBearer } from "@snokam/identity";
3
+ import { type HostOwner, PLATFORM_HOST, UNKNOWN_HOST } from "./seal";
4
+
5
+ function hostOf(url: string | undefined): string | null {
6
+ return url && URL.canParse(url) ? new URL(url).hostname.toLowerCase() : null;
7
+ }
8
+
9
+ function isPlatformHost(host: string): boolean {
10
+ return [
11
+ process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
12
+ process.env.NEXT_PUBLIC_CENTRAL_AUTH_URL,
13
+ ].some((url) => hostOf(url) === host);
14
+ }
15
+
16
+ export async function hostOwner(url: string): Promise<HostOwner> {
17
+ const host = new URL(url).hostname.toLowerCase();
18
+ if (isLocalHost(host) || isPlatformHost(host)) return PLATFORM_HOST;
19
+
20
+ const tenant = await resolveTenantByHostStrict({
21
+ host,
22
+ platformApiBase: process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "",
23
+ authToken: await getManagedBearer(),
24
+ });
25
+ return tenant?.businessId ? { businessId: tenant.businessId } : UNKNOWN_HOST;
26
+ }
@@ -7,7 +7,13 @@ import {
7
7
  } from "../session";
8
8
  import { sealHandoff } from "./ticket";
9
9
  import { isAllowedReturnTo } from "./return-to";
10
+ import { hostOwner } from "./business";
11
+ import { type HostOwner, maySealFor, PLATFORM_HOST } from "./seal";
10
12
  import { revokePasswordToken } from "../password/api";
13
+ import { resolveAccessToken } from "../route-handler/token";
14
+ import { tokenClaims } from "../impersonation/claims";
15
+ import { impersonationPickerUrl } from "../impersonation/required";
16
+ import type { Auth } from "../types";
11
17
 
12
18
  interface HandoffConfig {
13
19
  nextAuthSecret: string;
@@ -75,6 +81,41 @@ function completeRedirect(
75
81
  return Response.redirect(complete.toString(), 302);
76
82
  }
77
83
 
84
+ async function callerBusiness(
85
+ authConfig: Auth.Config,
86
+ request: Request
87
+ ): Promise<string | undefined | Response> {
88
+ const caller = await resolveAccessToken(authConfig, request);
89
+ return "error" in caller ? caller.error : tokenClaims(caller.accessToken).biz;
90
+ }
91
+
92
+ async function handOff(
93
+ request: Request,
94
+ authConfig: Auth.Config,
95
+ token: HandoffToken,
96
+ returnTo: string
97
+ ): Promise<Response> {
98
+ let owner: HostOwner;
99
+ try {
100
+ owner = await hostOwner(returnTo);
101
+ } catch {
102
+ return new Response("Tenant lookup unavailable", { status: 503 });
103
+ }
104
+
105
+ if (owner !== PLATFORM_HOST) {
106
+ const business = await callerBusiness(authConfig, request);
107
+ if (business instanceof Response) return business;
108
+ if (!maySealFor(owner, business)) {
109
+ return Response.redirect(
110
+ impersonationPickerUrl(requestOrigin(request), returnTo),
111
+ 302
112
+ );
113
+ }
114
+ }
115
+
116
+ return completeRedirect(token, returnTo, authConfig.nextAuthSecret);
117
+ }
118
+
78
119
  function expireCookie(name: string, domain?: string, secure?: boolean): string {
79
120
  return [
80
121
  `${name}=`,
@@ -109,7 +150,7 @@ export function createHandoffLogoutHandler(config?: HandoffConfig) {
109
150
  };
110
151
  }
111
152
 
112
- export function createHandoffStartHandler(config: HandoffConfig) {
153
+ export function createHandoffStartHandler(authConfig: Auth.Config) {
113
154
  return async function GET(request: Request) {
114
155
  const returnTo = await validReturnTo(request);
115
156
  if (!returnTo) {
@@ -117,9 +158,9 @@ export function createHandoffStartHandler(config: HandoffConfig) {
117
158
  }
118
159
 
119
160
  const prompt = new URL(request.url).searchParams.get("prompt");
120
- const token = await centralToken(request, config.nextAuthSecret);
161
+ const token = await centralToken(request, authConfig.nextAuthSecret);
121
162
  if (token?.refreshToken && !prompt) {
122
- return completeRedirect(token, returnTo, config.nextAuthSecret);
163
+ return handOff(request, authConfig, token, returnTo);
123
164
  }
124
165
 
125
166
  const origin = requestOrigin(request);
@@ -130,20 +171,20 @@ export function createHandoffStartHandler(config: HandoffConfig) {
130
171
  };
131
172
  }
132
173
 
133
- export function createHandoffFinishHandler(config: HandoffConfig) {
174
+ export function createHandoffFinishHandler(authConfig: Auth.Config) {
134
175
  return async function GET(request: Request) {
135
176
  const returnTo = await validReturnTo(request);
136
177
  if (!returnTo) {
137
178
  return new Response("Invalid returnTo", { status: 400 });
138
179
  }
139
180
 
140
- const token = await centralToken(request, config.nextAuthSecret);
181
+ const token = await centralToken(request, authConfig.nextAuthSecret);
141
182
  if (!token?.refreshToken) {
142
183
  const origin = requestOrigin(request);
143
184
  const start = `${origin}/handoff/start?returnTo=${encodeURIComponent(returnTo)}`;
144
185
  return Response.redirect(start, 302);
145
186
  }
146
187
 
147
- return completeRedirect(token, returnTo, config.nextAuthSecret);
188
+ return handOff(request, authConfig, token, returnTo);
148
189
  };
149
190
  }
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { maySealFor, PLATFORM_HOST, UNKNOWN_HOST } from "./seal.ts";
4
+
5
+ describe("handoff sealing", () => {
6
+ it("seals for hosts of the caller's own business", () => {
7
+ assert.equal(maySealFor({ businessId: "biz-own" }, "biz-own"), true);
8
+ });
9
+
10
+ it("seals for the platform's own hosts and localhost", () => {
11
+ assert.equal(maySealFor(PLATFORM_HOST, "biz-own"), true);
12
+ });
13
+
14
+ it("refuses hosts of another business, admins included", () => {
15
+ assert.equal(maySealFor({ businessId: "biz-other" }, "biz-own"), false);
16
+ });
17
+
18
+ it("refuses business hosts when the caller has no business", () => {
19
+ assert.equal(maySealFor({ businessId: "biz-other" }, undefined), false);
20
+ });
21
+
22
+ it("refuses a host that resolves to no business", () => {
23
+ assert.equal(maySealFor(UNKNOWN_HOST, "biz-own"), false);
24
+ });
25
+ });
@@ -0,0 +1,14 @@
1
+ export const PLATFORM_HOST = "platform";
2
+ export const UNKNOWN_HOST = "unknown";
3
+
4
+ export type HostOwner =
5
+ typeof PLATFORM_HOST | typeof UNKNOWN_HOST | { businessId: string };
6
+
7
+ export function maySealFor(
8
+ owner: HostOwner,
9
+ callerBusiness: string | undefined
10
+ ): boolean {
11
+ if (owner === PLATFORM_HOST) return true;
12
+ if (owner === UNKNOWN_HOST) return false;
13
+ return owner.businessId === callerBusiness;
14
+ }
@@ -2,6 +2,8 @@ export interface TokenClaims {
2
2
  biz?: string;
3
3
  email?: string;
4
4
  act?: string;
5
+ imp?: string;
6
+ prs?: string;
5
7
  }
6
8
 
7
9
  export function tokenClaims(accessToken: string): TokenClaims {
@@ -3,7 +3,9 @@ import { Auth } from "../types";
3
3
  import { resolveAccessToken } from "../route-handler/token";
4
4
  import { tokenClaims } from "./claims";
5
5
  import { platformCall, platformClient, platformNotConfigured } from "./request";
6
- import { impersonationRequired } from "./required";
6
+ import { FOREIGN_BUSINESS_ERROR, personaRequired } from "./required";
7
+ import { hostOwner } from "../handoff/business";
8
+ import { maySealFor } from "../handoff/seal";
7
9
 
8
10
  function persona(employee: ProtectedEmployee) {
9
11
  return {
@@ -22,34 +24,40 @@ function persona(employee: ProtectedEmployee) {
22
24
  };
23
25
  }
24
26
 
27
+ async function isForeignReturn(
28
+ request: Request,
29
+ callerBusiness: string | undefined
30
+ ): Promise<boolean> {
31
+ const returnTo = new URL(request.url).searchParams.get("returnTo");
32
+ if (!returnTo || !URL.canParse(returnTo)) return false;
33
+ return !maySealFor(await hostOwner(returnTo), callerBusiness);
34
+ }
35
+
25
36
  export function createImpersonationPersonasHandler(authConfig: Auth.Config) {
26
37
  return async function GET(request: Request) {
27
- if (!impersonationRequired(authConfig.environment)) {
28
- return new Response("Not found", { status: 404 });
29
- }
30
-
31
38
  const caller = await resolveAccessToken(authConfig, request);
32
39
  if ("error" in caller) return caller.error;
33
40
 
41
+ const claims = tokenClaims(caller.accessToken);
42
+ if (await isForeignReturn(request, claims.biz)) {
43
+ return Response.json({ error: FOREIGN_BUSINESS_ERROR }, { status: 403 });
44
+ }
45
+ if (!personaRequired(claims)) {
46
+ return new Response("Not found", { status: 404 });
47
+ }
48
+
34
49
  const client = platformClient(caller.accessToken);
35
50
  if (!client) return platformNotConfigured();
36
51
 
37
- const businessId = tokenClaims(caller.accessToken).biz;
38
- if (!businessId) {
39
- return new Response("The signed-in account has no business", {
40
- status: 409,
41
- });
42
- }
43
-
44
- const [employees, apps] = await Promise.all([
45
- platformCall(client.listBusinessEmployees({ id: businessId })),
52
+ const [personas, apps] = await Promise.all([
53
+ platformCall(client.listTestPersonas()),
46
54
  platformCall(client.getMeApps()),
47
55
  ]);
48
- if ("error" in employees) return employees.error;
56
+ if ("error" in personas) return personas.error;
49
57
  if ("error" in apps) return apps.error;
50
58
 
51
59
  return Response.json({
52
- personas: employees.value.map(persona),
60
+ personas: personas.value.map(persona),
53
61
  apps: apps.value,
54
62
  });
55
63
  };
@@ -1,22 +1,58 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
+ chooseToken,
4
5
  impersonationPickerUrl,
5
- impersonationRequired,
6
6
  isSignInPage,
7
+ PERSONA_REQUIRED,
8
+ personaRequired,
7
9
  renewDelayMs,
8
10
  } from "./required.ts";
9
11
 
10
- describe("impersonation required", () => {
11
- it("is off in production and everywhere that is not the test environment", () => {
12
- for (const environment of ["p", "test", "production", "", undefined]) {
13
- assert.equal(impersonationRequired(environment), false);
14
- }
12
+ describe("token choice for the token route and MCP sign-in", () => {
13
+ const persona = { email: "persona@test.snokam.no", accessToken: "imp" };
14
+ const snokamSso = { email: "dev@snokam.no", accessToken: "own" };
15
+ const customerOwner = { email: "owner@acme.no", accessToken: "own" };
16
+
17
+ it("sends a persona-mode user without a persona to the picker", () => {
18
+ assert.equal(chooseToken(null, snokamSso, { prs: "1" }), PERSONA_REQUIRED);
19
+ });
20
+
21
+ it("signs as the chosen persona once a Vis som session is active", () => {
22
+ assert.equal(chooseToken(persona, snokamSso, { prs: "1" }), persona);
23
+ });
24
+
25
+ it("signs a customer as themselves", () => {
26
+ assert.equal(chooseToken(null, customerOwner, {}), customerOwner);
15
27
  });
16
28
 
17
- it("is on in test", () => {
18
- assert.equal(impersonationRequired("t"), true);
29
+ it("has nothing to sign with when there is no session", () => {
30
+ assert.equal(chooseToken(null, null, {}), null);
19
31
  });
32
+ });
33
+
34
+ describe("persona required", () => {
35
+ const cases = [
36
+ { prs: false, imp: false, act: false, required: false },
37
+ { prs: false, imp: true, act: true, required: false },
38
+ { prs: false, imp: false, act: true, required: false },
39
+ { prs: false, imp: true, act: false, required: false },
40
+ { prs: true, imp: false, act: false, required: true },
41
+ { prs: true, imp: true, act: true, required: false },
42
+ { prs: true, imp: false, act: true, required: false },
43
+ { prs: true, imp: true, act: false, required: false },
44
+ ];
45
+
46
+ for (const { prs, imp, act, required } of cases) {
47
+ it(`is ${required} for prs=${prs} imp=${imp} act=${act}`, () => {
48
+ const claims = {
49
+ ...(prs ? { prs: "1" } : {}),
50
+ ...(imp ? { imp: "true" } : {}),
51
+ ...(act ? { act: "dev@snokam.no" } : {}),
52
+ };
53
+ assert.equal(personaRequired(claims), required);
54
+ });
55
+ }
20
56
 
21
57
  it("points the picker back at the page the person asked for", () => {
22
58
  const url = new URL(
@@ -1,15 +1,37 @@
1
+ import type { TokenClaims } from "./claims";
2
+
1
3
  export const IMPERSONATION_REQUIRED_ERROR = "impersonation_required";
2
4
 
3
- const TEST_ENVIRONMENT = "t";
5
+ export const FOREIGN_BUSINESS_ERROR = "foreign_business";
4
6
 
5
7
  export const IMPERSONATION_PICKER_PATH = "/velg-bruker";
6
8
 
7
9
  const RENEW_MARGIN_SECONDS = 120;
8
10
 
9
- export function impersonationRequired(
10
- environment: string | undefined
11
- ): boolean {
12
- return environment === TEST_ENVIRONMENT;
11
+ export const PERSONA_REQUIRED = "persona_required";
12
+
13
+ export function personaRequired(ownClaims: TokenClaims): boolean {
14
+ return !!ownClaims.prs && !ownClaims.imp && !ownClaims.act;
15
+ }
16
+
17
+ export function chooseToken<T>(
18
+ viewAs: T | null,
19
+ own: T | null,
20
+ ownClaims: TokenClaims
21
+ ): T | typeof PERSONA_REQUIRED | null {
22
+ if (viewAs) return viewAs;
23
+ if (!own) return null;
24
+ return personaRequired(ownClaims) ? PERSONA_REQUIRED : own;
25
+ }
26
+
27
+ export function impersonationRequiredResponse(): Response {
28
+ return Response.json(
29
+ {
30
+ error: IMPERSONATION_REQUIRED_ERROR,
31
+ platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
32
+ },
33
+ { status: 403 }
34
+ );
13
35
  }
14
36
 
15
37
  export function isSignInPage(pathname: string): boolean {
@@ -3,7 +3,11 @@ import AzureADProvider from "next-auth/providers/azure-ad";
3
3
  import CredentialsProvider from "next-auth/providers/credentials";
4
4
  import { getDefaultAuthParams } from "../options/params";
5
5
  import { openHandoff } from "../handoff/ticket";
6
- import { passwordLogin, redeemBootstrapToken } from "../password/api";
6
+ import {
7
+ enrolmentRequiredError,
8
+ passwordLogin,
9
+ redeemBootstrapToken,
10
+ } from "../password/api";
7
11
  import { requestClientIp, requestHost } from "./request";
8
12
  import { Auth } from "../types";
9
13
 
@@ -100,6 +104,8 @@ function password() {
100
104
  },
101
105
  requestClientIp(req)
102
106
  );
107
+ if (outcome.enrolment)
108
+ throw new Error(enrolmentRequiredError(outcome.enrolment));
103
109
  if (!outcome.login)
104
110
  throw new Error(outcome.error ?? "invalid_credentials");
105
111
 
@@ -1,3 +1,5 @@
1
+ export { enrolmentFromError, enrolmentRequiredError } from "./enrolment";
2
+
1
3
  export interface PasswordLoginResult {
2
4
  userId: string;
3
5
  businessId: string;
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { enrolmentFromError, enrolmentRequiredError } from "./enrolment.ts";
4
+
5
+ const enrolment = {
6
+ email: "owner@businessx.no",
7
+ enrolmentToken: "token+/=",
8
+ otpauthUri: "otpauth://totp/Sn%C3%B8sky:owner?secret=ABC&issuer=Sn%C3%B8sky",
9
+ };
10
+
11
+ describe("totp enrolment carried by the sign-in error", () => {
12
+ it("survives the round trip through the sign-in error url", () => {
13
+ const url = new URL("https://login.test.snosky.no/auth/login");
14
+ url.searchParams.set("error", enrolmentRequiredError(enrolment));
15
+ const error = new URL(url.toString()).searchParams.get("error");
16
+ assert.deepEqual(enrolmentFromError(error), enrolment);
17
+ });
18
+
19
+ it("ignores every other sign-in error", () => {
20
+ assert.equal(enrolmentFromError("invalid_credentials"), null);
21
+ assert.equal(enrolmentFromError("totp_enrollment_required"), null);
22
+ assert.equal(enrolmentFromError(undefined), null);
23
+ });
24
+ });
@@ -0,0 +1,15 @@
1
+ import type { PasswordEnrolment } from "./api";
2
+
3
+ const ENROLMENT_REQUIRED = "totp_enrollment_required";
4
+ const PREFIX = `${ENROLMENT_REQUIRED}:`;
5
+
6
+ export function enrolmentRequiredError(enrolment: PasswordEnrolment): string {
7
+ return `${PREFIX}${JSON.stringify(enrolment)}`;
8
+ }
9
+
10
+ export function enrolmentFromError(
11
+ error: string | undefined | null
12
+ ): PasswordEnrolment | null {
13
+ if (!error?.startsWith(PREFIX)) return null;
14
+ return JSON.parse(error.slice(PREFIX.length)) as PasswordEnrolment;
15
+ }
@@ -8,9 +8,11 @@ import { getAuthUrl, getSessionCookieSettings } from "../session";
8
8
  import { exchangePasswordToken } from "../password/api";
9
9
  import { readImpersonationSession } from "../impersonation/cookies";
10
10
  import { exchangeSsoToken } from "../sso/exchange";
11
+ import { tokenClaims } from "../impersonation/claims";
11
12
  import {
12
- IMPERSONATION_REQUIRED_ERROR,
13
- impersonationRequired,
13
+ chooseToken,
14
+ impersonationRequiredResponse,
15
+ PERSONA_REQUIRED,
14
16
  } from "../impersonation/required";
15
17
 
16
18
  export type TokenResult =
@@ -114,20 +116,14 @@ export function createTokenHandler(authConfig: Auth.Config) {
114
116
  });
115
117
  }
116
118
 
117
- if (impersonationRequired(authConfig.environment)) {
118
- return Response.json(
119
- {
120
- error: IMPERSONATION_REQUIRED_ERROR,
121
- platformUrl: process.env.NEXT_PUBLIC_PLATFORM_BASE_URL,
122
- },
123
- { status: 403 }
124
- );
125
- }
126
-
127
119
  const result = await resolveAccessToken(authConfig, request);
128
120
  if ("error" in result) {
129
121
  return result.error;
130
122
  }
131
- return Response.json(result);
123
+ const chosen = chooseToken(null, result, tokenClaims(result.accessToken));
124
+ if (chosen === PERSONA_REQUIRED) {
125
+ return impersonationRequiredResponse();
126
+ }
127
+ return Response.json(chosen);
132
128
  };
133
129
  }
package/src/types.ts CHANGED
@@ -8,7 +8,6 @@ export namespace Auth {
8
8
  clientSecret: string;
9
9
  nextAuthSecret: string;
10
10
  tenantId: string;
11
- environment?: string;
12
11
  }
13
12
  export namespace Provider {
14
13
  export interface Props {