@snokam/auth 2.2.22 → 2.3.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.
- package/CHANGELOG.md +366 -1027
- package/package.json +36 -48
- package/src/app-router/components/AuthComplete.styled.tsx +17 -0
- package/src/app-router/components/AuthComplete.tsx +52 -0
- package/src/app-router/components/Login.tsx +120 -0
- package/src/app-router/components/Provider/index.tsx +55 -0
- package/src/app-router/index.tsx +56 -0
- package/src/app-router/server.ts +93 -0
- package/src/build-proxy.tsx +160 -0
- package/src/components/Authenticate.tsx +38 -0
- package/src/components/Authenticated.tsx +11 -0
- package/src/components/Consent/index.tsx +35 -0
- package/src/components/EmployeeGate/index.tsx +59 -0
- package/src/components/Error/index.tsx +90 -0
- package/src/components/Error/styled.tsx +63 -0
- package/src/components/InactiveEmployee/index.tsx +18 -0
- package/src/components/LoggedOut/index.tsx +23 -0
- package/src/components/NotProvisioned/index.tsx +19 -0
- package/src/components/Unauthenticated.tsx +11 -0
- package/src/config.ts +9 -0
- package/src/handoff/handlers.ts +146 -0
- package/src/handoff/return-to.ts +40 -0
- package/src/handoff/ticket.ts +97 -0
- package/src/hooks.ts +146 -0
- package/src/impersonation/context.tsx +185 -0
- package/src/impersonation/cookies.ts +124 -0
- package/src/impersonation/handlers.ts +234 -0
- package/src/impersonation/routes.ts +30 -0
- package/src/impersonation/ticket.ts +79 -0
- package/src/index.tsx +31 -0
- package/src/options.tsx +389 -0
- package/src/page-router/components/Login.tsx +50 -0
- package/src/page-router/components/Provider/index.tsx +45 -0
- package/src/page-router/index.tsx +22 -0
- package/src/page-router/server.ts +6 -0
- package/src/password/api.ts +183 -0
- package/src/roles/context.tsx +54 -0
- package/src/route-handler/token.ts +124 -0
- package/src/route-handler.ts +52 -0
- package/src/scopes/context.tsx +127 -0
- package/src/scopes/matcher.ts +47 -0
- package/src/server/auth.tsx +30 -0
- package/src/server/handler.tsx +68 -0
- package/src/server/token.tsx +10 -0
- package/{lib/server.d.ts → src/server.tsx} +7 -4
- package/src/sso/exchange.ts +64 -0
- package/src/sso/tenant-cookie.ts +18 -0
- package/src/theme/employee-theme-sync.tsx +55 -0
- package/src/token/claims.ts +12 -0
- package/src/utils/callback-url.ts +17 -0
- package/src/utils/consent.ts +22 -0
- package/src/utils/cookies.ts +75 -0
- package/src/utils/cors.ts +68 -0
- package/src/utils/params.ts +15 -0
- package/src/utils/types.ts +52 -0
- package/src/utils/url.ts +19 -0
- package/lib/components/Authenticate.d.ts +0 -2
- package/lib/components/Authenticated.d.ts +0 -6
- package/lib/components/Error/index.d.ts +0 -5
- package/lib/components/Login.d.ts +0 -2
- package/lib/components/Provider/index.d.ts +0 -7
- package/lib/components/Unauthenticated.d.ts +0 -6
- package/lib/hooks.d.ts +0 -10
- package/lib/index.d.ts +0 -14
- package/lib/index.js +0 -42
- package/lib/index.js.map +0 -1
- package/lib/middleware.d.ts +0 -7
- package/lib/middleware.js +0 -2
- package/lib/middleware.js.map +0 -1
- package/lib/options.d.ts +0 -5
- package/lib/options.js +0 -2
- package/lib/options.js.map +0 -1
- package/lib/server/auth.d.ts +0 -11
- package/lib/server/handler.d.ts +0 -14
- package/lib/server/token.d.ts +0 -8
- package/lib/server.js +0 -2
- package/lib/server.js.map +0 -1
- package/lib/utils/cookies.d.ts +0 -5
- package/lib/utils/params.d.ts +0 -1
- 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
|
+
}
|