@snokam/auth 2.2.22-alpha.0 → 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.
- package/CHANGELOG.md +374 -1041
- 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,30 @@
|
|
|
1
|
+
import { Auth } from "../utils/types";
|
|
2
|
+
import {
|
|
3
|
+
createImpersonationStartHandler,
|
|
4
|
+
createImpersonationEndHandler,
|
|
5
|
+
createImpersonationAdoptHandler,
|
|
6
|
+
} from "./handlers";
|
|
7
|
+
|
|
8
|
+
type OpContext = { params: Promise<{ op: string }> };
|
|
9
|
+
|
|
10
|
+
// One catch-all mount per app: api/impersonation/[op]/route.ts re-exports { GET, POST }.
|
|
11
|
+
// POST /start + /end, GET /adopt — so every app supports view-as with a single file.
|
|
12
|
+
export function createImpersonationRouteHandlers(authConfig: Auth.Config) {
|
|
13
|
+
const start = createImpersonationStartHandler(authConfig);
|
|
14
|
+
const end = createImpersonationEndHandler(authConfig);
|
|
15
|
+
const adopt = createImpersonationAdoptHandler(authConfig);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
async GET(request: Request, ctx: OpContext) {
|
|
19
|
+
const { op } = await ctx.params;
|
|
20
|
+
if (op === "adopt") return adopt(request);
|
|
21
|
+
return new Response("Not found", { status: 404 });
|
|
22
|
+
},
|
|
23
|
+
async POST(request: Request, ctx: OpContext) {
|
|
24
|
+
const { op } = await ctx.params;
|
|
25
|
+
if (op === "start") return start(request);
|
|
26
|
+
if (op === "end") return end(request);
|
|
27
|
+
return new Response("Not found", { status: 404 });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "crypto";
|
|
7
|
+
|
|
8
|
+
// Cross-domain Vis som hand-off, sealed with the shared NEXTAUTH_SECRET — same trust model as login's ticket.
|
|
9
|
+
export interface ImpersonationTicket {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
expiresOn: number;
|
|
12
|
+
tokenId: string;
|
|
13
|
+
targetEmail: string;
|
|
14
|
+
targetName?: string | null;
|
|
15
|
+
finance: boolean;
|
|
16
|
+
business: string;
|
|
17
|
+
returnTo?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ALGORITHM = "aes-256-gcm";
|
|
21
|
+
const TTL_SECONDS = 120;
|
|
22
|
+
|
|
23
|
+
const keyFromSecret = (secret: string): Buffer => {
|
|
24
|
+
if (!secret)
|
|
25
|
+
throw new Error(
|
|
26
|
+
"NEXTAUTH_SECRET is empty; refusing to seal/open a forgeable impersonation ticket."
|
|
27
|
+
);
|
|
28
|
+
return createHash("sha256").update(secret).digest();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function sealImpersonationTicket(
|
|
32
|
+
claims: ImpersonationTicket,
|
|
33
|
+
secret: string,
|
|
34
|
+
ttlSeconds: number = TTL_SECONDS
|
|
35
|
+
): string {
|
|
36
|
+
const payload = JSON.stringify({
|
|
37
|
+
...claims,
|
|
38
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
39
|
+
});
|
|
40
|
+
const iv = randomBytes(12);
|
|
41
|
+
const cipher = createCipheriv(ALGORITHM, keyFromSecret(secret), iv);
|
|
42
|
+
const ciphertext = Buffer.concat([
|
|
43
|
+
cipher.update(payload, "utf8"),
|
|
44
|
+
cipher.final(),
|
|
45
|
+
]);
|
|
46
|
+
return [iv, cipher.getAuthTag(), ciphertext]
|
|
47
|
+
.map((part) => part.toString("base64url"))
|
|
48
|
+
.join(".");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function openImpersonationTicket(
|
|
52
|
+
ticket: string,
|
|
53
|
+
secret: string,
|
|
54
|
+
expectedBusiness?: string,
|
|
55
|
+
now: number = Date.now()
|
|
56
|
+
): ImpersonationTicket | null {
|
|
57
|
+
const parts = ticket.split(".");
|
|
58
|
+
if (parts.length !== 3) return null;
|
|
59
|
+
try {
|
|
60
|
+
const [iv, tag, ciphertext] = parts.map((part) =>
|
|
61
|
+
Buffer.from(part, "base64url")
|
|
62
|
+
);
|
|
63
|
+
const decipher = createDecipheriv(ALGORITHM, keyFromSecret(secret), iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
const plaintext = Buffer.concat([
|
|
66
|
+
decipher.update(ciphertext),
|
|
67
|
+
decipher.final(),
|
|
68
|
+
]).toString("utf8");
|
|
69
|
+
const payload = JSON.parse(plaintext) as ImpersonationTicket & {
|
|
70
|
+
exp: number;
|
|
71
|
+
};
|
|
72
|
+
if (payload.exp * 1000 < now) return null;
|
|
73
|
+
if (expectedBusiness && payload.business !== expectedBusiness) return null;
|
|
74
|
+
const { exp: _exp, ...claims } = payload;
|
|
75
|
+
return claims;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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 };
|
package/src/options.tsx
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import NextAuth, { NextAuthOptions } from "next-auth";
|
|
2
|
+
import AzureADProvider from "next-auth/providers/azure-ad";
|
|
3
|
+
import CredentialsProvider from "next-auth/providers/credentials";
|
|
4
|
+
import { getDefaultAuthParams } from "./utils/params";
|
|
5
|
+
import { ConfidentialClientApplication } from "@azure/msal-node";
|
|
6
|
+
import { Configuration, EmployeesClient } from "@snokam/employees-client";
|
|
7
|
+
import { headers } from "next/headers";
|
|
8
|
+
import {
|
|
9
|
+
getTenantDomains,
|
|
10
|
+
getTenantDomainsByEntraTenantId,
|
|
11
|
+
type TenantDomain,
|
|
12
|
+
} from "@snokam/config";
|
|
13
|
+
import { getManagedBearer } from "@snokam/identity";
|
|
14
|
+
import { exchangePasswordToken } from "./password/api";
|
|
15
|
+
import { exchangeSsoToken } from "./sso/exchange";
|
|
16
|
+
import { getSanityImageUrl } from "@snokam/ui/media/sanity";
|
|
17
|
+
import { getSessionCookieSettings } from "./utils/cookies";
|
|
18
|
+
import { getAuthUrl } from "./utils/url";
|
|
19
|
+
import { openHandoff } from "./handoff/ticket";
|
|
20
|
+
import { passwordLogin, redeemBootstrapToken } from "./password/api";
|
|
21
|
+
import { Auth } from "./utils/types";
|
|
22
|
+
|
|
23
|
+
async function resolveEmployeesBackend(
|
|
24
|
+
config: Auth.Config
|
|
25
|
+
): Promise<{ base: string; scope: string }> {
|
|
26
|
+
const scope = config.clientId
|
|
27
|
+
? `api://${config.clientId}/user_impersonation`
|
|
28
|
+
: "";
|
|
29
|
+
const requestHeaders = await headers();
|
|
30
|
+
const host =
|
|
31
|
+
requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
|
|
32
|
+
const platformApiBase = process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "";
|
|
33
|
+
const authToken = await getManagedBearer(scope);
|
|
34
|
+
const employeesBackend = (domains: TenantDomain[]) =>
|
|
35
|
+
domains.find(
|
|
36
|
+
(domain) => domain.type === "backend" && domain.app === "employees"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
let match = employeesBackend(
|
|
40
|
+
await getTenantDomains({ host, platformApiBase, authToken })
|
|
41
|
+
);
|
|
42
|
+
if (!match?.url && config.tenantId) {
|
|
43
|
+
match = employeesBackend(
|
|
44
|
+
await getTenantDomainsByEntraTenantId({
|
|
45
|
+
entraTenantId: config.tenantId,
|
|
46
|
+
platformApiBase,
|
|
47
|
+
authToken,
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (match?.url) return { base: match.url, scope };
|
|
52
|
+
|
|
53
|
+
const bare = host?.split(":")[0];
|
|
54
|
+
const effective =
|
|
55
|
+
!bare || bare === "localhost" || bare === "127.0.0.1"
|
|
56
|
+
? process.env.NEXT_PUBLIC_TENANT_HOST?.split(":")[0]
|
|
57
|
+
: host;
|
|
58
|
+
return { base: effective ? `https://${effective}/api/employees` : "", scope };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ColorScheme = "light" | "dark" | "system";
|
|
62
|
+
|
|
63
|
+
function isColorScheme(value: unknown): value is ColorScheme {
|
|
64
|
+
return value === "light" || value === "dark" || value === "system";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requestHost(req?: {
|
|
68
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
69
|
+
}): string | undefined {
|
|
70
|
+
const header = req?.headers?.["x-forwarded-host"] ?? req?.headers?.host;
|
|
71
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
72
|
+
return value?.split(",")[0].trim().split(":")[0] || undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requestClientIp(req?: {
|
|
76
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
77
|
+
}): string | undefined {
|
|
78
|
+
const pick = (name: string): string | undefined => {
|
|
79
|
+
const header = req?.headers?.[name];
|
|
80
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
81
|
+
return value?.split(",")[0].trim() || undefined;
|
|
82
|
+
};
|
|
83
|
+
// Trusted edge headers first (Front Door, unspoofable); rightmost XFF entry as a fallback.
|
|
84
|
+
const edge = pick("x-azure-clientip") ?? pick("x-azure-socketip");
|
|
85
|
+
if (edge) return edge;
|
|
86
|
+
const forwarded = req?.headers?.["x-forwarded-for"];
|
|
87
|
+
const value = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
88
|
+
return value?.split(",").pop()?.trim() || undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function requestOrigin(): Promise<string | undefined> {
|
|
92
|
+
const h = await headers();
|
|
93
|
+
const host = (h.get("x-forwarded-host") ?? h.get("host") ?? "")
|
|
94
|
+
.split(",")[0]
|
|
95
|
+
.trim();
|
|
96
|
+
if (!host) return undefined;
|
|
97
|
+
const scheme =
|
|
98
|
+
h.get("x-forwarded-proto") ??
|
|
99
|
+
(host.startsWith("localhost") ? "http" : "https");
|
|
100
|
+
return `${scheme}://${host}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface EmployeeProfile {
|
|
104
|
+
colorScheme?: ColorScheme;
|
|
105
|
+
imageUrl?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchEmployeeProfile(
|
|
109
|
+
config: Auth.Config,
|
|
110
|
+
refreshToken?: string,
|
|
111
|
+
authMethod?: string
|
|
112
|
+
): Promise<EmployeeProfile> {
|
|
113
|
+
if (!refreshToken) {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const { base, scope } = await resolveEmployeesBackend(config);
|
|
118
|
+
if (!base || !scope) return {};
|
|
119
|
+
|
|
120
|
+
let accessToken: string | undefined;
|
|
121
|
+
if (authMethod === "password") {
|
|
122
|
+
accessToken = (await exchangePasswordToken(refreshToken))?.accessToken;
|
|
123
|
+
} else {
|
|
124
|
+
const entra = (
|
|
125
|
+
await new ConfidentialClientApplication({
|
|
126
|
+
auth: {
|
|
127
|
+
clientId: config.clientId,
|
|
128
|
+
clientSecret: config.clientSecret,
|
|
129
|
+
authority: `https://login.microsoftonline.com/${config.tenantId}`,
|
|
130
|
+
},
|
|
131
|
+
}).acquireTokenByRefreshToken({ scopes: [scope], refreshToken })
|
|
132
|
+
)?.accessToken;
|
|
133
|
+
accessToken = entra
|
|
134
|
+
? (await exchangeSsoToken(entra))?.accessToken
|
|
135
|
+
: undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!accessToken) return {};
|
|
139
|
+
const client = new EmployeesClient(
|
|
140
|
+
new Configuration({
|
|
141
|
+
basePath: base,
|
|
142
|
+
accessToken: `Bearer ${accessToken}`,
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
const me = await client.getMeProtected();
|
|
146
|
+
const profile = {
|
|
147
|
+
colorScheme: isColorScheme(me.settings?.colorScheme)
|
|
148
|
+
? me.settings.colorScheme
|
|
149
|
+
: undefined,
|
|
150
|
+
imageUrl: getSanityImageUrl(me.image?.asset?.url, {
|
|
151
|
+
width: 128,
|
|
152
|
+
height: 128,
|
|
153
|
+
fit: "crop",
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
if (!profile.imageUrl) {
|
|
157
|
+
console.warn("[auth] employee profile resolved without an image");
|
|
158
|
+
}
|
|
159
|
+
return profile;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.warn("[auth] employee profile fetch failed", error);
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const getNextAuthOptions = (config: Auth.Config): NextAuthOptions => {
|
|
167
|
+
const authUrl = getAuthUrl();
|
|
168
|
+
const { name, domain, secure } = getSessionCookieSettings(authUrl);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
secret: config.nextAuthSecret,
|
|
172
|
+
session: {
|
|
173
|
+
maxAge: 7 * 24 * 60 * 60,
|
|
174
|
+
updateAge: 24 * 60 * 60,
|
|
175
|
+
},
|
|
176
|
+
pages: {
|
|
177
|
+
signIn: "/auth/login",
|
|
178
|
+
},
|
|
179
|
+
providers: [
|
|
180
|
+
AzureADProvider({
|
|
181
|
+
clientId: config.clientId,
|
|
182
|
+
clientSecret: config.clientSecret,
|
|
183
|
+
tenantId: config.tenantId,
|
|
184
|
+
authorization: {
|
|
185
|
+
params: getDefaultAuthParams(config.tenantId),
|
|
186
|
+
},
|
|
187
|
+
checks: ["pkce", "state"],
|
|
188
|
+
profile(profile) {
|
|
189
|
+
return {
|
|
190
|
+
id: profile.sub,
|
|
191
|
+
name: profile.name,
|
|
192
|
+
email: profile.email,
|
|
193
|
+
image: null,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
CredentialsProvider({
|
|
198
|
+
id: "handoff",
|
|
199
|
+
name: "Snokam",
|
|
200
|
+
credentials: { ticket: { label: "ticket", type: "text" } },
|
|
201
|
+
async authorize(credentials, req) {
|
|
202
|
+
const expectedBusiness = requestHost(req);
|
|
203
|
+
const claims =
|
|
204
|
+
credentials?.ticket && expectedBusiness
|
|
205
|
+
? openHandoff(
|
|
206
|
+
credentials.ticket,
|
|
207
|
+
config.nextAuthSecret,
|
|
208
|
+
expectedBusiness
|
|
209
|
+
)
|
|
210
|
+
: null;
|
|
211
|
+
if (!claims) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id: claims.oid,
|
|
216
|
+
email: claims.email,
|
|
217
|
+
name: claims.name,
|
|
218
|
+
image: claims.image ?? null,
|
|
219
|
+
oid: claims.oid,
|
|
220
|
+
tid: claims.tid,
|
|
221
|
+
refreshToken: claims.refreshToken,
|
|
222
|
+
authMethod: claims.authMethod || undefined,
|
|
223
|
+
} as unknown as { id: string };
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
CredentialsProvider({
|
|
227
|
+
id: "password",
|
|
228
|
+
name: "Passord",
|
|
229
|
+
credentials: {
|
|
230
|
+
email: { label: "email", type: "text" },
|
|
231
|
+
password: { label: "password", type: "password" },
|
|
232
|
+
code: { label: "code", type: "text" },
|
|
233
|
+
host: { label: "host", type: "text" },
|
|
234
|
+
bootstrap: { label: "bootstrap", type: "text" },
|
|
235
|
+
},
|
|
236
|
+
async authorize(credentials, req) {
|
|
237
|
+
if (credentials?.bootstrap) {
|
|
238
|
+
const redeemed = await redeemBootstrapToken(credentials.bootstrap);
|
|
239
|
+
if (!redeemed) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
id: redeemed.userId,
|
|
244
|
+
email: redeemed.email,
|
|
245
|
+
name: redeemed.name,
|
|
246
|
+
oid: redeemed.userId,
|
|
247
|
+
tid: redeemed.businessId,
|
|
248
|
+
refreshToken: redeemed.refreshToken,
|
|
249
|
+
authMethod: "password",
|
|
250
|
+
} as unknown as { id: string };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
!credentials?.email ||
|
|
255
|
+
!credentials.password ||
|
|
256
|
+
!credentials.host
|
|
257
|
+
) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const outcome = await passwordLogin(
|
|
262
|
+
{
|
|
263
|
+
host: credentials.host,
|
|
264
|
+
email: credentials.email,
|
|
265
|
+
password: credentials.password,
|
|
266
|
+
code: credentials.code || undefined,
|
|
267
|
+
},
|
|
268
|
+
requestClientIp(req)
|
|
269
|
+
);
|
|
270
|
+
if (!outcome.login) {
|
|
271
|
+
throw new Error(outcome.error ?? "invalid_credentials");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
id: outcome.login.userId,
|
|
276
|
+
email: outcome.login.email,
|
|
277
|
+
name: outcome.login.name,
|
|
278
|
+
oid: outcome.login.userId,
|
|
279
|
+
tid: outcome.login.businessId,
|
|
280
|
+
refreshToken: outcome.login.refreshToken,
|
|
281
|
+
authMethod: "password",
|
|
282
|
+
} as unknown as { id: string };
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
],
|
|
286
|
+
cookies: {
|
|
287
|
+
sessionToken: {
|
|
288
|
+
name,
|
|
289
|
+
options: {
|
|
290
|
+
path: "/",
|
|
291
|
+
httpOnly: true,
|
|
292
|
+
sameSite: "lax",
|
|
293
|
+
secure,
|
|
294
|
+
domain,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
callbacks: {
|
|
299
|
+
async redirect({ url, baseUrl }) {
|
|
300
|
+
const origin = (await requestOrigin()) ?? baseUrl;
|
|
301
|
+
const { pathname, search, hash } = new URL(url, origin);
|
|
302
|
+
return origin + pathname + search + hash;
|
|
303
|
+
},
|
|
304
|
+
async jwt({ token, account, profile, user, trigger, session }) {
|
|
305
|
+
if (account?.access_token) {
|
|
306
|
+
token.refreshToken = account.refresh_token;
|
|
307
|
+
token.expiresAt = account.expires_at;
|
|
308
|
+
const claims = profile as { oid?: string; tid?: string } | undefined;
|
|
309
|
+
if (claims?.oid) token.oid = claims.oid;
|
|
310
|
+
if (claims?.tid) token.tid = claims.tid;
|
|
311
|
+
const employee = await fetchEmployeeProfile(
|
|
312
|
+
{ ...config, tenantId: claims?.tid ?? config.tenantId },
|
|
313
|
+
account.refresh_token as string | undefined
|
|
314
|
+
);
|
|
315
|
+
if (employee.colorScheme) token.colorScheme = employee.colorScheme;
|
|
316
|
+
token.picture = employee.imageUrl ?? null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const handoff = user as
|
|
320
|
+
| {
|
|
321
|
+
oid?: string;
|
|
322
|
+
tid?: string;
|
|
323
|
+
refreshToken?: string;
|
|
324
|
+
authMethod?: string;
|
|
325
|
+
}
|
|
326
|
+
| undefined;
|
|
327
|
+
if (handoff?.refreshToken) {
|
|
328
|
+
token.oid = handoff.oid;
|
|
329
|
+
token.tid = handoff.tid;
|
|
330
|
+
token.refreshToken = handoff.refreshToken;
|
|
331
|
+
if (handoff.authMethod) {
|
|
332
|
+
token.authMethod = handoff.authMethod;
|
|
333
|
+
}
|
|
334
|
+
const employee = await fetchEmployeeProfile(
|
|
335
|
+
{ ...config, tenantId: handoff.tid ?? config.tenantId },
|
|
336
|
+
handoff.refreshToken,
|
|
337
|
+
handoff.authMethod
|
|
338
|
+
);
|
|
339
|
+
if (employee.colorScheme) token.colorScheme = employee.colorScheme;
|
|
340
|
+
if (employee.imageUrl) token.picture = employee.imageUrl;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (trigger === "update") {
|
|
344
|
+
if (isColorScheme(session?.colorScheme)) {
|
|
345
|
+
token.colorScheme = session.colorScheme;
|
|
346
|
+
}
|
|
347
|
+
if (session?.refreshProfile && token.refreshToken) {
|
|
348
|
+
const employee = await fetchEmployeeProfile(
|
|
349
|
+
{
|
|
350
|
+
...config,
|
|
351
|
+
tenantId: (token.tid as string | undefined) ?? config.tenantId,
|
|
352
|
+
},
|
|
353
|
+
token.refreshToken as string,
|
|
354
|
+
token.authMethod as string | undefined
|
|
355
|
+
);
|
|
356
|
+
if (employee.imageUrl) token.picture = employee.imageUrl;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return token;
|
|
361
|
+
},
|
|
362
|
+
async session({ session, token }) {
|
|
363
|
+
const claims = token as {
|
|
364
|
+
colorScheme?: unknown;
|
|
365
|
+
oid?: string;
|
|
366
|
+
tid?: string;
|
|
367
|
+
authMethod?: string;
|
|
368
|
+
};
|
|
369
|
+
const target = session as {
|
|
370
|
+
colorScheme?: ColorScheme;
|
|
371
|
+
oid?: string;
|
|
372
|
+
tid?: string;
|
|
373
|
+
authMethod?: string;
|
|
374
|
+
};
|
|
375
|
+
if (isColorScheme(claims.colorScheme)) {
|
|
376
|
+
target.colorScheme = claims.colorScheme;
|
|
377
|
+
}
|
|
378
|
+
target.oid = claims.oid;
|
|
379
|
+
target.tid = claims.tid;
|
|
380
|
+
if (claims.authMethod) {
|
|
381
|
+
target.authMethod = claims.authMethod;
|
|
382
|
+
}
|
|
383
|
+
return session;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
export default (config: Auth.Config) => NextAuth(getNextAuthOptions(config));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
|
+
import { useRouter } from "next/router";
|
|
3
|
+
import { useAuth } from "../../hooks";
|
|
4
|
+
import { signIn } from "next-auth/react";
|
|
5
|
+
import { PageLoader } from "@snokam/ui";
|
|
6
|
+
import Error from "../../components/Error";
|
|
7
|
+
|
|
8
|
+
export const Login = () => {
|
|
9
|
+
const { isAuthenticated } = useAuth();
|
|
10
|
+
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
|
|
11
|
+
const [authParams, setAuthParams] = useState<Record<string, string>>({});
|
|
12
|
+
const [isError, setIsError] = useState<boolean>(false);
|
|
13
|
+
const hasSignedIn = useRef(false);
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const params = new URLSearchParams(window.location.search);
|
|
18
|
+
setCallbackUrl(params.get("callbackUrl"));
|
|
19
|
+
setIsError(!!params.get("error"));
|
|
20
|
+
|
|
21
|
+
const authParamsObj: Record<string, string> = {};
|
|
22
|
+
params.forEach((value, key) => {
|
|
23
|
+
if (key !== "error" && key !== "callbackUrl") {
|
|
24
|
+
authParamsObj[key] = value;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
setAuthParams(authParamsObj);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!callbackUrl) return;
|
|
32
|
+
if (isError) return;
|
|
33
|
+
if (hasSignedIn.current) return;
|
|
34
|
+
|
|
35
|
+
if (isAuthenticated) {
|
|
36
|
+
router.replace(callbackUrl || "/");
|
|
37
|
+
} else {
|
|
38
|
+
hasSignedIn.current = true;
|
|
39
|
+
signIn("azure-ad", { callbackUrl }, authParams);
|
|
40
|
+
}
|
|
41
|
+
}, [isAuthenticated, callbackUrl, authParams, isError, router, signIn]);
|
|
42
|
+
|
|
43
|
+
if (isError) {
|
|
44
|
+
return <Error />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return <PageLoader />;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default Login;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { memo, ReactNode, useEffect } from "react";
|
|
2
|
+
import { useRouter } from "next/router";
|
|
3
|
+
import { Auth } from "../../../utils/types";
|
|
4
|
+
import { Authenticate } from "../../../components/Authenticate";
|
|
5
|
+
import { SessionProvider } from "next-auth/react";
|
|
6
|
+
import { useAuth } from "../../../hooks";
|
|
7
|
+
|
|
8
|
+
export const Provider = memo((props: Auth.Provider.Props) => {
|
|
9
|
+
const { session, children, enforceLogin } = props;
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const query = router?.query;
|
|
12
|
+
|
|
13
|
+
if (query?.enforceLogin || enforceLogin) {
|
|
14
|
+
return (
|
|
15
|
+
<SessionProvider session={session}>
|
|
16
|
+
<Authenticate provider="azure-ad" {...props}>
|
|
17
|
+
{children}
|
|
18
|
+
</Authenticate>
|
|
19
|
+
</SessionProvider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<SessionProvider session={session}>
|
|
25
|
+
<Wrapper>{children}</Wrapper>
|
|
26
|
+
</SessionProvider>
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const Wrapper = ({ children }: { children: ReactNode }) => {
|
|
31
|
+
const { signOut } = useAuth();
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (router?.query?.logout === "true") {
|
|
36
|
+
signOut({
|
|
37
|
+
callbackUrl: "/auth/login?callbackUrl=/",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}, [router?.query?.logout]);
|
|
41
|
+
|
|
42
|
+
return <>{children}</>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default Provider;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Provider } from "./components/Provider";
|
|
4
|
+
import { Authenticate } from "../components/Authenticate";
|
|
5
|
+
import { Login } from "./components/Login";
|
|
6
|
+
import { Error } from "../components/Error";
|
|
7
|
+
|
|
8
|
+
export { Unauthenticated } from "../components/Unauthenticated";
|
|
9
|
+
export { Authenticated } from "../components/Authenticated";
|
|
10
|
+
export { useAuth } from "../hooks";
|
|
11
|
+
export { signIn, SessionProvider } from "next-auth/react";
|
|
12
|
+
export { Login, Error, Provider, Authenticate };
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
Login,
|
|
16
|
+
Error,
|
|
17
|
+
Provider,
|
|
18
|
+
Authenticate,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type { Session } from "next-auth";
|
|
22
|
+
export type { Auth } from "../utils/types";
|