@snokam/auth 2.2.22 → 2.3.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +360 -1027
  2. package/package.json +36 -48
  3. package/src/app-router/components/AuthComplete.styled.tsx +17 -0
  4. package/src/app-router/components/AuthComplete.tsx +52 -0
  5. package/src/app-router/components/Login.tsx +120 -0
  6. package/src/app-router/components/Provider/index.tsx +55 -0
  7. package/src/app-router/index.tsx +56 -0
  8. package/src/app-router/server.ts +93 -0
  9. package/src/build-proxy.tsx +160 -0
  10. package/src/components/Authenticate.tsx +38 -0
  11. package/src/components/Authenticated.tsx +11 -0
  12. package/src/components/Consent/index.tsx +35 -0
  13. package/src/components/EmployeeGate/index.tsx +59 -0
  14. package/src/components/Error/index.tsx +90 -0
  15. package/src/components/Error/styled.tsx +63 -0
  16. package/src/components/InactiveEmployee/index.tsx +18 -0
  17. package/src/components/LoggedOut/index.tsx +23 -0
  18. package/src/components/NotProvisioned/index.tsx +19 -0
  19. package/src/components/Unauthenticated.tsx +11 -0
  20. package/src/config.ts +9 -0
  21. package/src/handoff/handlers.ts +146 -0
  22. package/src/handoff/return-to.ts +40 -0
  23. package/src/handoff/ticket.ts +97 -0
  24. package/src/hooks.ts +146 -0
  25. package/src/impersonation/context.tsx +185 -0
  26. package/src/impersonation/cookies.ts +124 -0
  27. package/src/impersonation/handlers.ts +234 -0
  28. package/src/impersonation/routes.ts +30 -0
  29. package/src/impersonation/ticket.ts +79 -0
  30. package/src/index.tsx +31 -0
  31. package/src/options.tsx +389 -0
  32. package/src/page-router/components/Login.tsx +50 -0
  33. package/src/page-router/components/Provider/index.tsx +45 -0
  34. package/src/page-router/index.tsx +22 -0
  35. package/src/page-router/server.ts +6 -0
  36. package/src/password/api.ts +183 -0
  37. package/src/roles/context.tsx +54 -0
  38. package/src/route-handler/token.ts +124 -0
  39. package/src/route-handler.ts +52 -0
  40. package/src/scopes/context.tsx +127 -0
  41. package/src/scopes/matcher.ts +47 -0
  42. package/src/server/auth.tsx +30 -0
  43. package/src/server/handler.tsx +68 -0
  44. package/src/server/token.tsx +10 -0
  45. package/{lib/server.d.ts → src/server.tsx} +7 -4
  46. package/src/sso/exchange.ts +64 -0
  47. package/src/sso/tenant-cookie.ts +18 -0
  48. package/src/theme/employee-theme-sync.tsx +55 -0
  49. package/src/token/claims.ts +12 -0
  50. package/src/utils/callback-url.ts +17 -0
  51. package/src/utils/consent.ts +22 -0
  52. package/src/utils/cookies.ts +75 -0
  53. package/src/utils/cors.ts +68 -0
  54. package/src/utils/params.ts +15 -0
  55. package/src/utils/types.ts +52 -0
  56. package/src/utils/url.ts +19 -0
  57. package/lib/components/Authenticate.d.ts +0 -2
  58. package/lib/components/Authenticated.d.ts +0 -6
  59. package/lib/components/Error/index.d.ts +0 -5
  60. package/lib/components/Login.d.ts +0 -2
  61. package/lib/components/Provider/index.d.ts +0 -7
  62. package/lib/components/Unauthenticated.d.ts +0 -6
  63. package/lib/hooks.d.ts +0 -10
  64. package/lib/index.d.ts +0 -14
  65. package/lib/index.js +0 -42
  66. package/lib/index.js.map +0 -1
  67. package/lib/middleware.d.ts +0 -7
  68. package/lib/middleware.js +0 -2
  69. package/lib/middleware.js.map +0 -1
  70. package/lib/options.d.ts +0 -5
  71. package/lib/options.js +0 -2
  72. package/lib/options.js.map +0 -1
  73. package/lib/server/auth.d.ts +0 -11
  74. package/lib/server/handler.d.ts +0 -14
  75. package/lib/server/token.d.ts +0 -8
  76. package/lib/server.js +0 -2
  77. package/lib/server.js.map +0 -1
  78. package/lib/utils/cookies.d.ts +0 -5
  79. package/lib/utils/params.d.ts +0 -1
  80. package/lib/utils/types.d.ts +0 -39
@@ -0,0 +1,183 @@
1
+ export interface PasswordLoginResult {
2
+ userId: string;
3
+ businessId: string;
4
+ email: string;
5
+ name: string;
6
+ refreshToken: string;
7
+ refreshTokenExpiresAt: string;
8
+ siteHost?: string;
9
+ }
10
+
11
+ export interface PasswordEnrolment {
12
+ email: string;
13
+ enrolmentToken: string;
14
+ otpauthUri: string;
15
+ }
16
+
17
+ export interface PasswordLoginOutcome {
18
+ login?: PasswordLoginResult;
19
+ enrolment?: PasswordEnrolment;
20
+ error?: string;
21
+ retryAfterSeconds?: number;
22
+ }
23
+
24
+ export interface TotpEnrollOutcome {
25
+ recoveryCodes?: string[];
26
+ bootstrapToken?: string;
27
+ siteHost?: string;
28
+ error?: string;
29
+ }
30
+
31
+ export interface TokenExchangeResult {
32
+ accessToken: string;
33
+ expiresOn: number;
34
+ }
35
+
36
+ export interface PasswordLoginConfig {
37
+ businessDisplayName: string;
38
+ passwordEnabled: boolean;
39
+ entraEnabled: boolean;
40
+ ssoActive: boolean;
41
+ ssoProvider?: string;
42
+ logoUrl?: string;
43
+ ssoDirectoryId?: string;
44
+ }
45
+
46
+ function apiBase(): string {
47
+ return (process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "").replace(
48
+ /\/+$/,
49
+ ""
50
+ );
51
+ }
52
+
53
+ async function post(
54
+ path: string,
55
+ body: unknown,
56
+ clientIp?: string
57
+ ): Promise<{ status: number; data: Record<string, unknown> | null }> {
58
+ const response = await fetch(`${apiBase()}/v1.0/public/auth${path}`, {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ ...(clientIp ? { "X-Forwarded-For": clientIp } : {}),
63
+ },
64
+ body: JSON.stringify(body),
65
+ cache: "no-store",
66
+ });
67
+
68
+ let data: Record<string, unknown> | null = null;
69
+ try {
70
+ data = (await response.json()) as Record<string, unknown>;
71
+ } catch {
72
+ data = null;
73
+ }
74
+ return { status: response.status, data };
75
+ }
76
+
77
+ export async function passwordLogin(
78
+ request: {
79
+ host: string;
80
+ email: string;
81
+ password: string;
82
+ code?: string;
83
+ },
84
+ clientIp?: string
85
+ ): Promise<PasswordLoginOutcome> {
86
+ const { status, data } = await post("/password/login", request, clientIp);
87
+ if (status === 200 && data) {
88
+ return { login: data as unknown as PasswordLoginResult };
89
+ }
90
+ return {
91
+ error: typeof data?.error === "string" ? data.error : "invalid_credentials",
92
+ enrolment: (data?.enrolment as PasswordEnrolment | undefined) ?? undefined,
93
+ retryAfterSeconds: status === 429 ? 60 : undefined,
94
+ };
95
+ }
96
+
97
+ export async function passwordActivate(
98
+ request: { token: string; password: string },
99
+ clientIp?: string
100
+ ): Promise<{ enrolment?: PasswordEnrolment; error?: string }> {
101
+ const { status, data } = await post("/password/activate", request, clientIp);
102
+ if (status === 200 && data) {
103
+ return { enrolment: data as unknown as PasswordEnrolment };
104
+ }
105
+ return {
106
+ error: typeof data?.error === "string" ? data.error : "invalid_token",
107
+ };
108
+ }
109
+
110
+ export async function passwordEnrollTotp(
111
+ request: { enrolmentToken: string; code: string },
112
+ clientIp?: string
113
+ ): Promise<TotpEnrollOutcome> {
114
+ const { status, data } = await post(
115
+ "/password/totp/enroll",
116
+ request,
117
+ clientIp
118
+ );
119
+ if (status === 200 && data) {
120
+ return data as unknown as TotpEnrollOutcome;
121
+ }
122
+ return {
123
+ error: typeof data?.error === "string" ? data.error : "invalid_token",
124
+ };
125
+ }
126
+
127
+ export async function passwordResetRequest(
128
+ request: { host: string; email: string },
129
+ clientIp?: string
130
+ ): Promise<void> {
131
+ await post("/password/reset/request", request, clientIp);
132
+ }
133
+
134
+ export async function passwordResetConfirm(
135
+ request: { token: string; password: string; code?: string },
136
+ clientIp?: string
137
+ ): Promise<{ ok: boolean; error?: string }> {
138
+ const { status, data } = await post(
139
+ "/password/reset/confirm",
140
+ request,
141
+ clientIp
142
+ );
143
+ if (status === 204) return { ok: true };
144
+ return {
145
+ ok: false,
146
+ error: typeof data?.error === "string" ? data.error : "invalid_token",
147
+ };
148
+ }
149
+
150
+ export async function exchangePasswordToken(
151
+ refreshToken: string
152
+ ): Promise<TokenExchangeResult | null> {
153
+ const { status, data } = await post("/password/token", { refreshToken });
154
+ if (status !== 200 || !data?.accessToken) return null;
155
+ return data as unknown as TokenExchangeResult;
156
+ }
157
+
158
+ export async function redeemBootstrapToken(
159
+ bootstrapToken: string
160
+ ): Promise<PasswordLoginResult | null> {
161
+ const { status, data } = await post("/password/session", { bootstrapToken });
162
+ if (status !== 200 || !data?.refreshToken) return null;
163
+ return data as unknown as PasswordLoginResult;
164
+ }
165
+
166
+ export async function revokePasswordToken(refreshToken: string): Promise<void> {
167
+ await post("/password/revoke", { refreshToken });
168
+ }
169
+
170
+ export async function fetchPasswordLoginConfig(
171
+ host: string
172
+ ): Promise<PasswordLoginConfig | null> {
173
+ try {
174
+ const response = await fetch(
175
+ `${apiBase()}/v1.0/public/auth/login-config?host=${encodeURIComponent(host)}`,
176
+ { next: { revalidate: 60 } } as RequestInit
177
+ );
178
+ if (!response.ok) return null;
179
+ return (await response.json()) as PasswordLoginConfig;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { useAuth } from "../hooks";
5
+ import { decodeJwtClaims } from "../token/claims";
6
+
7
+ export const PLATFORM_OPERATOR = "platform-operator";
8
+ export const PLATFORM_ADMIN = "platform-admin";
9
+
10
+ export interface RolesState {
11
+ roles: string[];
12
+ isLoading: boolean;
13
+ }
14
+
15
+ function decodeTokenRoles(bearer: string): string[] {
16
+ const claims = decodeJwtClaims<{ roles?: string[] | string }>(bearer);
17
+ if (!claims) return [];
18
+ if (Array.isArray(claims.roles)) return claims.roles;
19
+ if (typeof claims.roles === "string") return [claims.roles];
20
+ return [];
21
+ }
22
+
23
+ export function usePlatformRoles(): RolesState {
24
+ const { getToken, isAuthenticated } = useAuth();
25
+ const [roles, setRoles] = useState<string[] | null>(null);
26
+
27
+ useEffect(() => {
28
+ if (!isAuthenticated) {
29
+ setRoles(null);
30
+ return;
31
+ }
32
+ let cancelled = false;
33
+ getToken()
34
+ .then((bearer) => {
35
+ if (!cancelled) setRoles(decodeTokenRoles(bearer));
36
+ })
37
+ .catch(() => {
38
+ if (!cancelled) setRoles([]);
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, [isAuthenticated, getToken]);
44
+
45
+ return useMemo(
46
+ () => ({ roles: roles ?? [], isLoading: roles === null }),
47
+ [roles]
48
+ );
49
+ }
50
+
51
+ export function useHasPlatformRole(role: string): boolean {
52
+ const { roles } = usePlatformRoles();
53
+ return useMemo(() => roles.includes(role), [roles, role]);
54
+ }
@@ -0,0 +1,124 @@
1
+ import { getToken } from "next-auth/jwt";
2
+ import {
3
+ ConfidentialClientApplication,
4
+ InteractionRequiredAuthError,
5
+ } from "@azure/msal-node";
6
+ import { Auth } from "../utils/types";
7
+ import { getAuthUrl } from "../utils/url";
8
+ import { getSessionCookieSettings } from "../utils/cookies";
9
+ import { exchangePasswordToken } from "../password/api";
10
+ import { readImpersonationSession } from "../impersonation/cookies";
11
+ import { exchangeSsoToken } from "../sso/exchange";
12
+
13
+ export type TokenResult =
14
+ | { accessToken: string; expiresOn: number }
15
+ | { error: Response };
16
+
17
+ /**
18
+ * Resolves the SIGNED-IN admin's own product access token (never the impersonation token). Shared
19
+ * by the token handler and the impersonation start/end handlers so the mint/end calls to the
20
+ * platform are authenticated as the real admin.
21
+ */
22
+ export async function resolveAccessToken(
23
+ authConfig: Auth.Config,
24
+ request: Request
25
+ ): Promise<TokenResult> {
26
+ const { name: cookieName } = getSessionCookieSettings(getAuthUrl());
27
+ const token = await getToken({
28
+ req: request as any,
29
+ secret: authConfig.nextAuthSecret,
30
+ cookieName,
31
+ });
32
+
33
+ if (!token?.refreshToken) {
34
+ return { error: new Response("Not authenticated", { status: 401 }) };
35
+ }
36
+
37
+ if ((token as { authMethod?: string }).authMethod === "password") {
38
+ const exchanged = await exchangePasswordToken(token.refreshToken as string);
39
+ if (!exchanged) {
40
+ return { error: new Response("Not authenticated", { status: 401 }) };
41
+ }
42
+ return {
43
+ accessToken: exchanged.accessToken,
44
+ expiresOn: exchanged.expiresOn,
45
+ };
46
+ }
47
+
48
+ const scope = process.env.IDENTITY_AD_SCOPE;
49
+ if (!scope) {
50
+ return {
51
+ error: new Response("IDENTITY_AD_SCOPE is not configured", {
52
+ status: 500,
53
+ }),
54
+ };
55
+ }
56
+
57
+ const tenantId = (token as { tid?: string }).tid || authConfig.tenantId;
58
+
59
+ try {
60
+ const cca = new ConfidentialClientApplication({
61
+ auth: {
62
+ clientId: authConfig.clientId,
63
+ clientSecret: authConfig.clientSecret,
64
+ authority: `https://login.microsoftonline.com/${tenantId}`,
65
+ },
66
+ });
67
+
68
+ const accessTokenResult = await cca.acquireTokenByRefreshToken({
69
+ scopes: [scope],
70
+ refreshToken: token.refreshToken as string,
71
+ });
72
+
73
+ if (!accessTokenResult) {
74
+ return {
75
+ error: new Response("Failed to get access token", { status: 500 }),
76
+ };
77
+ }
78
+
79
+ const provider = {
80
+ accessToken: accessTokenResult.accessToken,
81
+ expiresOn: Math.floor(accessTokenResult.expiresOn!.getTime() / 1000),
82
+ };
83
+
84
+ const exchanged = await exchangeSsoToken(provider.accessToken);
85
+ if (!exchanged) {
86
+ return {
87
+ error: new Response("SSO token exchange failed", { status: 502 }),
88
+ };
89
+ }
90
+ return exchanged;
91
+ } catch (e) {
92
+ console.error("Token exchange error:", e);
93
+ if (e instanceof InteractionRequiredAuthError) {
94
+ return {
95
+ error: Response.json(
96
+ { error: "consent_required", tenantId },
97
+ { status: 403 }
98
+ ),
99
+ };
100
+ }
101
+ return { error: new Response("Token exchange failed", { status: 500 }) };
102
+ }
103
+ }
104
+
105
+ export function createTokenHandler(authConfig: Auth.Config) {
106
+ return async function POST(request: Request) {
107
+ // While a view-as-employee session is active, every product API call transparently receives the
108
+ // short-lived impersonation token instead of the admin's own — the single swap point the design
109
+ // relies on. A clean exit (or expiry) discards the cookie and this falls back to the admin token.
110
+ const impersonation = readImpersonationSession(request);
111
+ if (impersonation && impersonation.expiresOn * 1000 > Date.now()) {
112
+ return Response.json({
113
+ accessToken: impersonation.accessToken,
114
+ expiresOn: impersonation.expiresOn,
115
+ });
116
+ }
117
+
118
+ const result = await resolveAccessToken(authConfig, request);
119
+ if ("error" in result) {
120
+ return result.error;
121
+ }
122
+ return Response.json(result);
123
+ };
124
+ }
@@ -0,0 +1,52 @@
1
+ import NextAuth from "next-auth";
2
+ import type { NextAuthOptions } from "next-auth";
3
+ import { resolveCookieDomain } from "./utils/cookies";
4
+
5
+ type AuthOptionsFor = NextAuthOptions | ((req: Request) => NextAuthOptions);
6
+
7
+ export function createAuthHandler(options: AuthOptionsFor) {
8
+ return async function handler(req: Request, ctx: unknown) {
9
+ const authOptions = typeof options === "function" ? options(req) : options;
10
+ const host =
11
+ req.headers.get("x-forwarded-host") ??
12
+ req.headers.get("host") ??
13
+ undefined;
14
+ const domain = resolveCookieDomain(host);
15
+
16
+ const sessionToken = authOptions.cookies?.sessionToken;
17
+ const perRequest: NextAuthOptions = sessionToken
18
+ ? {
19
+ ...authOptions,
20
+ cookies: {
21
+ ...authOptions.cookies,
22
+ sessionToken: {
23
+ ...sessionToken,
24
+ options: { ...sessionToken.options, domain },
25
+ },
26
+ },
27
+ }
28
+ : authOptions;
29
+
30
+ return (
31
+ NextAuth(perRequest) as unknown as (
32
+ req: Request,
33
+ ctx: unknown
34
+ ) => Promise<Response>
35
+ )(req, ctx);
36
+ };
37
+ }
38
+
39
+ export { getNextAuthOptions } from "./options";
40
+ export { createTokenHandler } from "./route-handler/token";
41
+ export {
42
+ createImpersonationStartHandler,
43
+ createImpersonationEndHandler,
44
+ createImpersonationAdoptHandler,
45
+ } from "./impersonation/handlers";
46
+ export { createImpersonationRouteHandlers } from "./impersonation/routes";
47
+ export {
48
+ createHandoffStartHandler,
49
+ createHandoffFinishHandler,
50
+ createHandoffLogoutHandler,
51
+ } from "./handoff/handlers";
52
+ export type { Auth } from "./utils/types";
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { useAuth } from "../hooks";
12
+ import { IMP_ACTIVE_COOKIE } from "../impersonation/cookies";
13
+ import { scopeGranted, type ScopeAccess } from "./matcher";
14
+ import { decodeJwtClaims } from "../token/claims";
15
+
16
+ export interface ScopesState {
17
+ scopes: string[];
18
+ isImpersonating: boolean;
19
+ isAuthoritative: boolean;
20
+ isLoading: boolean;
21
+ }
22
+
23
+ const ScopesContext = createContext<ScopesState | null>(null);
24
+
25
+ function isImpersonationActive(): boolean {
26
+ if (typeof document === "undefined") return false;
27
+ const match = document.cookie
28
+ .split(";")
29
+ .map((part) => part.trim())
30
+ .find((part) => part.startsWith(`${IMP_ACTIVE_COOKIE}=`));
31
+ if (!match) return false;
32
+ try {
33
+ const value = decodeURIComponent(match.slice(IMP_ACTIVE_COOKIE.length + 1));
34
+ const parsed = JSON.parse(value) as { expiresOn?: number };
35
+ if (parsed.expiresOn && parsed.expiresOn * 1000 <= Date.now()) return false;
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function decodeTokenScopes(bearer: string): string[] {
43
+ const claims = decodeJwtClaims<{ scope?: string | string[] }>(bearer);
44
+ if (!claims) return [];
45
+ if (Array.isArray(claims.scope)) return claims.scope;
46
+ if (typeof claims.scope === "string")
47
+ return claims.scope.split(" ").filter(Boolean);
48
+ return [];
49
+ }
50
+
51
+ interface ScopesProviderProps {
52
+ scopes?: string[] | null;
53
+ ready?: boolean;
54
+ children: ReactNode;
55
+ }
56
+
57
+ export function ScopesProvider({
58
+ scopes: normalScopes,
59
+ ready,
60
+ children,
61
+ }: ScopesProviderProps) {
62
+ const { getToken, isAuthenticated } = useAuth();
63
+ const [impersonating] = useState(isImpersonationActive);
64
+ const [tokenScopes, setTokenScopes] = useState<string[] | null>(null);
65
+
66
+ useEffect(() => {
67
+ if (!impersonating || !isAuthenticated) {
68
+ setTokenScopes(null);
69
+ return;
70
+ }
71
+ let cancelled = false;
72
+ getToken()
73
+ .then((bearer) => {
74
+ if (!cancelled) setTokenScopes(decodeTokenScopes(bearer));
75
+ })
76
+ .catch(() => {
77
+ if (!cancelled) setTokenScopes([]);
78
+ });
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [impersonating, isAuthenticated, getToken]);
83
+
84
+ const value = useMemo<ScopesState>(() => {
85
+ if (impersonating) {
86
+ return {
87
+ scopes: tokenScopes ?? [],
88
+ isImpersonating: true,
89
+ isAuthoritative: tokenScopes !== null,
90
+ isLoading: tokenScopes === null,
91
+ };
92
+ }
93
+ const authoritative = ready ?? normalScopes != null;
94
+ return {
95
+ scopes: normalScopes ?? [],
96
+ isImpersonating: false,
97
+ isAuthoritative: authoritative,
98
+ isLoading: ready === false,
99
+ };
100
+ }, [impersonating, tokenScopes, normalScopes, ready]);
101
+
102
+ return (
103
+ <ScopesContext.Provider value={value}>{children}</ScopesContext.Provider>
104
+ );
105
+ }
106
+
107
+ export function useScopes(): ScopesState {
108
+ return (
109
+ useContext(ScopesContext) ?? {
110
+ scopes: [],
111
+ isImpersonating: false,
112
+ isAuthoritative: false,
113
+ isLoading: false,
114
+ }
115
+ );
116
+ }
117
+
118
+ export function useHasScope(
119
+ scope: string,
120
+ access: ScopeAccess = "read"
121
+ ): boolean {
122
+ const { scopes } = useScopes();
123
+ return useMemo(
124
+ () => scopeGranted(scopes, scope, access),
125
+ [scopes, scope, access]
126
+ );
127
+ }
@@ -0,0 +1,47 @@
1
+ export type ScopeAccess = "read" | "write";
2
+
3
+ function resourceCovers(granted: string, required: string): boolean {
4
+ return (
5
+ granted === required ||
6
+ required.toLowerCase().startsWith(`${granted.toLowerCase()}.`)
7
+ );
8
+ }
9
+
10
+ export function scopeGranted(
11
+ grantedScopes: readonly string[],
12
+ resource: string,
13
+ access: ScopeAccess = "read"
14
+ ): boolean {
15
+ for (const scope of grantedScopes) {
16
+ const separator = scope.indexOf(":");
17
+ const grantedResource = separator < 0 ? scope : scope.slice(0, separator);
18
+ if (!resourceCovers(grantedResource, resource)) continue;
19
+
20
+ const grantedAction = separator < 0 ? "*" : scope.slice(separator + 1);
21
+ if (grantedAction === "*") return true;
22
+ if (access === "read" && (grantedAction === "read" || grantedAction === "write"))
23
+ return true;
24
+ if (access === "write" && grantedAction === "write") return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ interface EmployeeLikeGroup {
30
+ scopeGrants?: string[] | null;
31
+ }
32
+
33
+ interface EmployeeLike {
34
+ scopeGrants?: string[] | null;
35
+ groups?: EmployeeLikeGroup[] | null;
36
+ }
37
+
38
+ export function resolveEmployeeScopes(
39
+ employee: EmployeeLike | null | undefined
40
+ ): string[] {
41
+ if (!employee) return [];
42
+ const scopes = new Set<string>();
43
+ for (const grant of employee.scopeGrants ?? []) scopes.add(grant);
44
+ for (const group of employee.groups ?? [])
45
+ for (const grant of group.scopeGrants ?? []) scopes.add(grant);
46
+ return [...scopes];
47
+ }
@@ -0,0 +1,30 @@
1
+ import { GetServerSidePropsContext, GetStaticPropsContext } from "next";
2
+ import { getServerSession as nextAuthGetServerSession } from "next-auth";
3
+ import { Auth } from "../utils/types";
4
+ import { getNextAuthOptions } from "../options";
5
+
6
+ export const getAuth = async (
7
+ context: GetServerSidePropsContext | GetStaticPropsContext,
8
+ authConfig: Auth.Config
9
+ ) => {
10
+ if (!("req" in context && "res" in context)) {
11
+ return {
12
+ isAuthenticated: false,
13
+ session: null,
14
+ user: null,
15
+ };
16
+ }
17
+
18
+ const options = getNextAuthOptions(authConfig);
19
+ const session = await nextAuthGetServerSession(
20
+ context.req,
21
+ context.res,
22
+ options
23
+ );
24
+
25
+ return {
26
+ isAuthenticated: !!session?.user,
27
+ user: session?.user || null,
28
+ session,
29
+ };
30
+ };
@@ -0,0 +1,68 @@
1
+ import { getToken } from "next-auth/jwt";
2
+ import { ConfidentialClientApplication } from "@azure/msal-node";
3
+ import type { NextApiRequest, NextApiResponse } from "next";
4
+ import { getServerSession } from "next-auth";
5
+ import { Auth } from "../utils/types";
6
+ import { getNextAuthOptions } from "../options";
7
+ import { exchangeSsoToken } from "../sso/exchange";
8
+
9
+ export interface Props {
10
+ req: NextApiRequest;
11
+ res: NextApiResponse<{ accessToken: string; expiresOn: number } | string>;
12
+ authConfig: Auth.Config;
13
+ }
14
+
15
+ export default async function handler(props: Props) {
16
+ const { req, res, authConfig } = props;
17
+ const options = getNextAuthOptions(authConfig);
18
+ const session = await getServerSession(req, res, options);
19
+
20
+ if (!session?.user) {
21
+ return res.status(401).end("Not authenticated");
22
+ }
23
+
24
+ const scope = process.env.IDENTITY_AD_SCOPE;
25
+ if (!scope) {
26
+ return res.status(500).end("IDENTITY_AD_SCOPE is not configured");
27
+ }
28
+
29
+ try {
30
+ const { nextAuthSecret } = authConfig;
31
+ const refetchTokenResult = await getToken({
32
+ req,
33
+ secret: nextAuthSecret,
34
+ cookieName: options.cookies?.sessionToken?.name,
35
+ });
36
+
37
+ if (!refetchTokenResult) {
38
+ return res.status(500).end("Failed to get refetch token");
39
+ }
40
+
41
+ const cca = new ConfidentialClientApplication({
42
+ auth: {
43
+ clientId: authConfig.clientId,
44
+ clientSecret: authConfig.clientSecret,
45
+ authority: `https://login.microsoftonline.com/${authConfig.tenantId}`,
46
+ },
47
+ });
48
+
49
+ const accessTokenResult = await cca.acquireTokenByRefreshToken({
50
+ scopes: [scope],
51
+ refreshToken: refetchTokenResult?.refreshToken as string,
52
+ });
53
+
54
+ if (!accessTokenResult) {
55
+ return res.status(500).end("Failed to get access token");
56
+ }
57
+
58
+ const exchanged = await exchangeSsoToken(accessTokenResult.accessToken);
59
+ if (!exchanged) {
60
+ return res.status(502).end("SSO token exchange failed");
61
+ }
62
+
63
+ return res.status(200).json(exchanged);
64
+ } catch (e) {
65
+ console.error("Token exchange error:", e);
66
+ res.status(500).end("Token exchange failed");
67
+ }
68
+ }
@@ -0,0 +1,10 @@
1
+ import { getManagedToken } from "@snokam/identity";
2
+
3
+ interface Config {
4
+ scopes: string[];
5
+ }
6
+
7
+ export const getServerToken = async ({ scopes }: Config) => {
8
+ const token = await getManagedToken(scopes[0]);
9
+ return `Bearer ${token}`;
10
+ };