@snokam/auth 2.3.3 → 3.0.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +644 -0
  2. package/package.json +11 -14
  3. package/src/app-router/components/Login.tsx +2 -2
  4. package/src/app-router/components/Provider/index.tsx +1 -1
  5. package/src/app-router/index.tsx +4 -4
  6. package/src/app-router/server.ts +16 -64
  7. package/src/components/Authenticate.tsx +2 -2
  8. package/src/components/Consent/index.tsx +1 -1
  9. package/src/components/Error/index.tsx +1 -1
  10. package/src/components/index.ts +5 -0
  11. package/src/config.ts +1 -1
  12. package/src/handoff/handlers.ts +6 -3
  13. package/src/handoff/return-to.ts +1 -1
  14. package/src/handoff/ticket.ts +7 -69
  15. package/src/impersonation/cookies.ts +1 -1
  16. package/src/impersonation/handlers.ts +2 -2
  17. package/src/impersonation/routes.ts +1 -1
  18. package/src/impersonation/ticket.ts +6 -51
  19. package/src/index.ts +11 -0
  20. package/src/{build-proxy.tsx → middleware.tsx} +3 -4
  21. package/src/options/callbacks.ts +99 -0
  22. package/src/options/cookies.ts +13 -0
  23. package/src/options/employee-profile.ts +90 -0
  24. package/src/options/index.tsx +19 -0
  25. package/src/options/providers.ts +117 -0
  26. package/src/options/request.ts +42 -0
  27. package/src/{route-handler.ts → route-handler/index.ts} +7 -7
  28. package/src/route-handler/token.ts +3 -5
  29. package/src/scopes/index.ts +8 -0
  30. package/src/scopes/matcher.ts +4 -1
  31. package/src/session/index.ts +3 -0
  32. package/src/theme/employee-theme-sync.tsx +1 -1
  33. package/src/ticket/seal.ts +95 -0
  34. package/src/index.tsx +0 -31
  35. package/src/options.tsx +0 -389
  36. package/src/page-router/components/Login.tsx +0 -50
  37. package/src/page-router/components/Provider/index.tsx +0 -45
  38. package/src/page-router/index.tsx +0 -22
  39. package/src/page-router/server.ts +0 -6
  40. package/src/server/auth.tsx +0 -30
  41. package/src/server/handler.tsx +0 -68
  42. package/src/server.tsx +0 -16
  43. /package/src/{server → app-router}/token.tsx +0 -0
  44. /package/src/{utils/consent.ts → consent.ts} +0 -0
  45. /package/src/{utils/cors.ts → cors.ts} +0 -0
  46. /package/src/{utils → options}/params.ts +0 -0
  47. /package/src/{utils → session}/callback-url.ts +0 -0
  48. /package/src/{utils → session}/cookies.ts +0 -0
  49. /package/src/{utils → session}/url.ts +0 -0
  50. /package/src/{utils/types.ts → types.ts} +0 -0
@@ -0,0 +1,90 @@
1
+ import { ConfidentialClientApplication } from "@azure/msal-node";
2
+ import { Configuration, EmployeesClient } from "@snokam/employees-client";
3
+ import { backendUrl } from "@snokam/tenant/server";
4
+ import { getSanityImageUrl } from "@snokam/ui/media/sanity";
5
+ import { exchangePasswordToken } from "../password/api";
6
+ import { exchangeSsoToken } from "../sso/exchange";
7
+ import { Auth } from "../types";
8
+
9
+ async function resolveEmployeesBackend(
10
+ config: Auth.Config
11
+ ): Promise<{ base: string; scope: string }> {
12
+ const scope = config.clientId
13
+ ? `api://${config.clientId}/user_impersonation`
14
+ : "";
15
+ return {
16
+ base: await backendUrl("employees", {
17
+ app: "employees",
18
+ entraTenantIdFallback: config.tenantId,
19
+ }),
20
+ scope,
21
+ };
22
+ }
23
+
24
+ export type ColorScheme = "light" | "dark" | "system";
25
+
26
+ export function isColorScheme(value: unknown): value is ColorScheme {
27
+ return value === "light" || value === "dark" || value === "system";
28
+ }
29
+ interface EmployeeProfile {
30
+ colorScheme?: ColorScheme;
31
+ imageUrl?: string;
32
+ }
33
+
34
+ export async function fetchEmployeeProfile(
35
+ config: Auth.Config,
36
+ refreshToken?: string,
37
+ authMethod?: string
38
+ ): Promise<EmployeeProfile> {
39
+ if (!refreshToken) {
40
+ return {};
41
+ }
42
+ try {
43
+ const { base, scope } = await resolveEmployeesBackend(config);
44
+ if (!base || !scope) return {};
45
+
46
+ let accessToken: string | undefined;
47
+ if (authMethod === "password") {
48
+ accessToken = (await exchangePasswordToken(refreshToken))?.accessToken;
49
+ } else {
50
+ const entra = (
51
+ await new ConfidentialClientApplication({
52
+ auth: {
53
+ clientId: config.clientId,
54
+ clientSecret: config.clientSecret,
55
+ authority: `https://login.microsoftonline.com/${config.tenantId}`,
56
+ },
57
+ }).acquireTokenByRefreshToken({ scopes: [scope], refreshToken })
58
+ )?.accessToken;
59
+ accessToken = entra
60
+ ? (await exchangeSsoToken(entra))?.accessToken
61
+ : undefined;
62
+ }
63
+
64
+ if (!accessToken) return {};
65
+ const client = new EmployeesClient(
66
+ new Configuration({
67
+ basePath: base,
68
+ accessToken: `Bearer ${accessToken}`,
69
+ })
70
+ );
71
+ const me = await client.getMeProtected();
72
+ const profile = {
73
+ colorScheme: isColorScheme(me.settings?.colorScheme)
74
+ ? me.settings.colorScheme
75
+ : undefined,
76
+ imageUrl: getSanityImageUrl(me.image?.asset?.url, {
77
+ width: 128,
78
+ height: 128,
79
+ fit: "crop",
80
+ }),
81
+ };
82
+ if (!profile.imageUrl) {
83
+ console.warn("[auth] employee profile resolved without an image");
84
+ }
85
+ return profile;
86
+ } catch (error) {
87
+ console.warn("[auth] employee profile fetch failed", error);
88
+ return {};
89
+ }
90
+ }
@@ -0,0 +1,19 @@
1
+ import NextAuth, { NextAuthOptions } from "next-auth";
2
+ import { callbacks } from "./callbacks";
3
+ import { cookies } from "./cookies";
4
+ import { providers } from "./providers";
5
+ import { Auth } from "../types";
6
+
7
+ const WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
8
+ const DAY_IN_SECONDS = 24 * 60 * 60;
9
+
10
+ export const getNextAuthOptions = (config: Auth.Config): NextAuthOptions => ({
11
+ secret: config.nextAuthSecret,
12
+ session: { maxAge: WEEK_IN_SECONDS, updateAge: DAY_IN_SECONDS },
13
+ pages: { signIn: "/auth/login" },
14
+ providers: providers(config),
15
+ cookies: cookies(),
16
+ callbacks: callbacks(config),
17
+ });
18
+
19
+ export default (config: Auth.Config) => NextAuth(getNextAuthOptions(config));
@@ -0,0 +1,117 @@
1
+ import type { Provider } from "next-auth/providers/index";
2
+ import AzureADProvider from "next-auth/providers/azure-ad";
3
+ import CredentialsProvider from "next-auth/providers/credentials";
4
+ import { getDefaultAuthParams } from "../options/params";
5
+ import { openHandoff } from "../handoff/ticket";
6
+ import { passwordLogin, redeemBootstrapToken } from "../password/api";
7
+ import { requestClientIp, requestHost } from "./request";
8
+ import { Auth } from "../types";
9
+
10
+ type AuthorizedUser = { id: string };
11
+
12
+ export function providers(config: Auth.Config): Provider[] {
13
+ return [entraId(config), handoff(config), password()];
14
+ }
15
+
16
+ function entraId(config: Auth.Config) {
17
+ return AzureADProvider({
18
+ clientId: config.clientId,
19
+ clientSecret: config.clientSecret,
20
+ tenantId: config.tenantId,
21
+ authorization: { params: getDefaultAuthParams(config.tenantId) },
22
+ checks: ["pkce", "state"],
23
+ profile(profile) {
24
+ return {
25
+ id: profile.sub,
26
+ name: profile.name,
27
+ email: profile.email,
28
+ image: null,
29
+ };
30
+ },
31
+ });
32
+ }
33
+
34
+ function handoff(config: Auth.Config) {
35
+ return CredentialsProvider({
36
+ id: "handoff",
37
+ name: "Snokam",
38
+ credentials: { ticket: { label: "ticket", type: "text" } },
39
+ async authorize(credentials, req) {
40
+ const expectedBusiness = requestHost(req);
41
+ if (!credentials?.ticket || !expectedBusiness) return null;
42
+
43
+ const claims = openHandoff(
44
+ credentials.ticket,
45
+ config.nextAuthSecret,
46
+ expectedBusiness
47
+ );
48
+ if (!claims) return null;
49
+
50
+ return {
51
+ id: claims.oid,
52
+ email: claims.email,
53
+ name: claims.name,
54
+ image: claims.image ?? null,
55
+ oid: claims.oid,
56
+ tid: claims.tid,
57
+ refreshToken: claims.refreshToken,
58
+ authMethod: claims.authMethod || undefined,
59
+ } as unknown as AuthorizedUser;
60
+ },
61
+ });
62
+ }
63
+
64
+ function password() {
65
+ return CredentialsProvider({
66
+ id: "password",
67
+ name: "Passord",
68
+ credentials: {
69
+ email: { label: "email", type: "text" },
70
+ password: { label: "password", type: "password" },
71
+ code: { label: "code", type: "text" },
72
+ host: { label: "host", type: "text" },
73
+ bootstrap: { label: "bootstrap", type: "text" },
74
+ },
75
+ async authorize(credentials, req) {
76
+ if (credentials?.bootstrap) {
77
+ const redeemed = await redeemBootstrapToken(credentials.bootstrap);
78
+ if (!redeemed) return null;
79
+
80
+ return {
81
+ id: redeemed.userId,
82
+ email: redeemed.email,
83
+ name: redeemed.name,
84
+ oid: redeemed.userId,
85
+ tid: redeemed.businessId,
86
+ refreshToken: redeemed.refreshToken,
87
+ authMethod: "password",
88
+ } as unknown as AuthorizedUser;
89
+ }
90
+
91
+ if (!credentials?.email || !credentials.password || !credentials.host)
92
+ return null;
93
+
94
+ const outcome = await passwordLogin(
95
+ {
96
+ host: credentials.host,
97
+ email: credentials.email,
98
+ password: credentials.password,
99
+ code: credentials.code || undefined,
100
+ },
101
+ requestClientIp(req)
102
+ );
103
+ if (!outcome.login)
104
+ throw new Error(outcome.error ?? "invalid_credentials");
105
+
106
+ return {
107
+ id: outcome.login.userId,
108
+ email: outcome.login.email,
109
+ name: outcome.login.name,
110
+ oid: outcome.login.userId,
111
+ tid: outcome.login.businessId,
112
+ refreshToken: outcome.login.refreshToken,
113
+ authMethod: "password",
114
+ } as unknown as AuthorizedUser;
115
+ },
116
+ });
117
+ }
@@ -0,0 +1,42 @@
1
+ import { headers } from "next/headers";
2
+
3
+ type HeaderBag = Record<string, string | string[] | undefined>;
4
+
5
+ function first(bag: HeaderBag | undefined, name: string): string | undefined {
6
+ const header = bag?.[name];
7
+ const value = Array.isArray(header) ? header[0] : header;
8
+ return value?.split(",")[0].trim() || undefined;
9
+ }
10
+
11
+ export function requestHost(req?: { headers?: HeaderBag }): string | undefined {
12
+ const header = req?.headers?.["x-forwarded-host"] ?? req?.headers?.host;
13
+ const value = Array.isArray(header) ? header[0] : header;
14
+ return value?.split(",")[0].trim().split(":")[0] || undefined;
15
+ }
16
+
17
+ export function requestClientIp(req?: {
18
+ headers?: HeaderBag;
19
+ }): string | undefined {
20
+ // Trusted edge headers first (Front Door, unspoofable); rightmost XFF entry as a fallback.
21
+ const edge =
22
+ first(req?.headers, "x-azure-clientip") ??
23
+ first(req?.headers, "x-azure-socketip");
24
+ if (edge) return edge;
25
+
26
+ const forwarded = req?.headers?.["x-forwarded-for"];
27
+ const value = Array.isArray(forwarded) ? forwarded[0] : forwarded;
28
+ return value?.split(",").pop()?.trim() || undefined;
29
+ }
30
+
31
+ export async function requestOrigin(): Promise<string | undefined> {
32
+ const incoming = await headers();
33
+ const host = (incoming.get("x-forwarded-host") ?? incoming.get("host") ?? "")
34
+ .split(",")[0]
35
+ .trim();
36
+ if (!host) return undefined;
37
+
38
+ const scheme =
39
+ incoming.get("x-forwarded-proto") ??
40
+ (host.startsWith("localhost") ? "http" : "https");
41
+ return `${scheme}://${host}`;
42
+ }
@@ -1,6 +1,6 @@
1
1
  import NextAuth from "next-auth";
2
2
  import type { NextAuthOptions } from "next-auth";
3
- import { resolveCookieDomain } from "./utils/cookies";
3
+ import { resolveCookieDomain } from "../session";
4
4
 
5
5
  type AuthOptionsFor = NextAuthOptions | ((req: Request) => NextAuthOptions);
6
6
 
@@ -36,17 +36,17 @@ export function createAuthHandler(options: AuthOptionsFor) {
36
36
  };
37
37
  }
38
38
 
39
- export { getNextAuthOptions } from "./options";
40
- export { createTokenHandler } from "./route-handler/token";
39
+ export { getNextAuthOptions } from "../options";
40
+ export { createTokenHandler } from "./token";
41
41
  export {
42
42
  createImpersonationStartHandler,
43
43
  createImpersonationEndHandler,
44
44
  createImpersonationAdoptHandler,
45
- } from "./impersonation/handlers";
46
- export { createImpersonationRouteHandlers } from "./impersonation/routes";
45
+ } from "../impersonation/handlers";
46
+ export { createImpersonationRouteHandlers } from "../impersonation/routes";
47
47
  export {
48
48
  createHandoffStartHandler,
49
49
  createHandoffFinishHandler,
50
50
  createHandoffLogoutHandler,
51
- } from "./handoff/handlers";
52
- export type { Auth } from "./utils/types";
51
+ } from "../handoff/handlers";
52
+ export type { Auth } from "../types";
@@ -3,16 +3,14 @@ import {
3
3
  ConfidentialClientApplication,
4
4
  InteractionRequiredAuthError,
5
5
  } from "@azure/msal-node";
6
- import { Auth } from "../utils/types";
7
- import { getAuthUrl } from "../utils/url";
8
- import { getSessionCookieSettings } from "../utils/cookies";
6
+ import { Auth } from "../types";
7
+ import { getAuthUrl, getSessionCookieSettings } from "../session";
9
8
  import { exchangePasswordToken } from "../password/api";
10
9
  import { readImpersonationSession } from "../impersonation/cookies";
11
10
  import { exchangeSsoToken } from "../sso/exchange";
12
11
 
13
12
  export type TokenResult =
14
- | { accessToken: string; expiresOn: number }
15
- | { error: Response };
13
+ { accessToken: string; expiresOn: number } | { error: Response };
16
14
 
17
15
  /**
18
16
  * Resolves the SIGNED-IN admin's own product access token (never the impersonation token). Shared
@@ -0,0 +1,8 @@
1
+ export { ScopesProvider, useScopes, useHasScope } from "./context";
2
+ export type { ScopesState } from "./context";
3
+
4
+ export {
5
+ scopeGranted,
6
+ resolveEmployeeScopes,
7
+ type ScopeAccess,
8
+ } from "./matcher";
@@ -19,7 +19,10 @@ export function scopeGranted(
19
19
 
20
20
  const grantedAction = separator < 0 ? "*" : scope.slice(separator + 1);
21
21
  if (grantedAction === "*") return true;
22
- if (access === "read" && (grantedAction === "read" || grantedAction === "write"))
22
+ if (
23
+ access === "read" &&
24
+ (grantedAction === "read" || grantedAction === "write")
25
+ )
23
26
  return true;
24
27
  if (access === "write" && grantedAction === "write") return true;
25
28
  }
@@ -0,0 +1,3 @@
1
+ export { resolveCookieDomain, getSessionCookieSettings } from "./cookies";
2
+ export { getAuthUrl, requestOrigin } from "./url";
3
+ export { safeCallbackTarget } from "./callback-url";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import { Configuration, EmployeesClient } from "@snokam/employees-client";
5
- import { useOptionalConfig } from "@snokam/config";
5
+ import { useOptionalConfig } from "@snokam/tenant";
6
6
  import { useClientQuery } from "@snokam/react-query";
7
7
  import { useAdminTheme } from "@snokam/ui/theme/admin";
8
8
  import type { ColorSchemeMode } from "@snokam/ui/theme/admin";
@@ -0,0 +1,95 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "crypto";
7
+
8
+ const ALGORITHM = "aes-256-gcm";
9
+
10
+ interface OpenOptions {
11
+ expectedBusiness?: string;
12
+ now?: number;
13
+ onReject?: (reason: string) => void;
14
+ }
15
+
16
+ function keyFromSecret(secret: string, purpose: string): Buffer {
17
+ if (!secret)
18
+ throw new Error(
19
+ `NEXTAUTH_SECRET is empty; refusing to seal/open a forgeable ${purpose} ticket.`
20
+ );
21
+ return createHash("sha256").update(secret).digest();
22
+ }
23
+
24
+ export function seal<T extends object>(
25
+ claims: T,
26
+ secret: string,
27
+ ttlSeconds: number,
28
+ purpose: string
29
+ ): string {
30
+ const payload = JSON.stringify({
31
+ ...claims,
32
+ exp: Math.floor(Date.now() / 1000) + ttlSeconds,
33
+ });
34
+ const iv = randomBytes(12);
35
+ const cipher = createCipheriv(ALGORITHM, keyFromSecret(secret, purpose), iv);
36
+ const ciphertext = Buffer.concat([
37
+ cipher.update(payload, "utf8"),
38
+ cipher.final(),
39
+ ]);
40
+
41
+ return [iv, cipher.getAuthTag(), ciphertext]
42
+ .map((part) => part.toString("base64url"))
43
+ .join(".");
44
+ }
45
+
46
+ export function open<T extends { business: string }>(
47
+ ticket: string,
48
+ secret: string,
49
+ purpose: string,
50
+ options: OpenOptions = {}
51
+ ): T | null {
52
+ const { expectedBusiness, now = Date.now(), onReject } = options;
53
+ const reject = (reason: string): null => {
54
+ onReject?.(reason);
55
+ return null;
56
+ };
57
+
58
+ const parts = ticket.split(".");
59
+ if (parts.length !== 3)
60
+ return reject("malformed ticket (expected iv.tag.ciphertext)");
61
+
62
+ try {
63
+ const [iv, tag, ciphertext] = parts.map((part) =>
64
+ Buffer.from(part, "base64url")
65
+ );
66
+ const decipher = createDecipheriv(
67
+ ALGORITHM,
68
+ keyFromSecret(secret, purpose),
69
+ iv
70
+ );
71
+ decipher.setAuthTag(tag);
72
+ const plaintext = Buffer.concat([
73
+ decipher.update(ciphertext),
74
+ decipher.final(),
75
+ ]).toString("utf8");
76
+
77
+ const payload = JSON.parse(plaintext) as T & { exp: number };
78
+ if (payload.exp * 1000 < now)
79
+ return reject(
80
+ `expired (exp ${payload.exp}, now ${Math.floor(now / 1000)})`
81
+ );
82
+
83
+ if (expectedBusiness && payload.business !== expectedBusiness)
84
+ return reject(
85
+ `business mismatch (ticket "${payload.business}" vs host "${expectedBusiness}")`
86
+ );
87
+
88
+ const { exp: _exp, ...claims } = payload;
89
+ return claims as unknown as T;
90
+ } catch (error) {
91
+ return reject(
92
+ `decrypt failed (secret mismatch or tampered): ${(error as Error).message}`
93
+ );
94
+ }
95
+ }
package/src/index.tsx DELETED
@@ -1,31 +0,0 @@
1
- import { Provider } from "./page-router/components/Provider";
2
- import { Authenticate } from "./components/Authenticate";
3
- import { Login } from "./page-router/components/Login";
4
- import { Auth } from "./utils/types";
5
- import { Error } from "./components/Error";
6
-
7
- export { Unauthenticated } from "./components/Unauthenticated";
8
- export { Authenticated } from "./components/Authenticated";
9
-
10
- export { useAuth, clearAuthTokenCache } from "./hooks";
11
- export { ScopesProvider, useScopes, useHasScope } from "./scopes/context";
12
- export type { ScopesState } from "./scopes/context";
13
- export {
14
- scopeGranted,
15
- resolveEmployeeScopes,
16
- type ScopeAccess,
17
- } from "./scopes/matcher";
18
- export { Consent } from "./components/Consent";
19
- export { buildAdminConsentUrl } from "./utils/consent";
20
- export type { AdminConsentParams } from "./utils/consent";
21
- export { default as middleware } from "next-auth/middleware";
22
- export { signIn } from "next-auth/react";
23
-
24
- export default {
25
- Login,
26
- Error,
27
- Provider,
28
- Authenticate,
29
- };
30
-
31
- export type { Auth };