@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,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 };
@@ -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";
@@ -0,0 +1,6 @@
1
+ export { getServerToken } from "../server/token";
2
+ export { getServerSession } from "next-auth/next";
3
+ export { getNextAuthOptions } from "../options";
4
+ export { getAuth } from "../server/auth";
5
+
6
+ export type { Auth } from "../utils/types";