@evenicanpm/storefront-core 2.1.0 → 2.2.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/package.json +4 -3
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +7 -5
- package/src/api-manager/datasources/e4/middleware.ts +35 -0
- package/src/api-manager/lib/get-graphql-client.ts +7 -6
- package/src/api-manager/services/create-query.ts +1 -46
- package/src/auth/better-auth.ts +89 -0
- package/src/auth/signout.ts +5 -34
- package/src/components/header/__tests__/header.test.tsx +9 -2
- package/src/components/header/__tests__/user.test.tsx +34 -19
- package/src/components/header/components/user.tsx +34 -18
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +3 -1
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header.tsx +3 -1
- package/src/hooks/use-nextauth-session.ts +10 -14
- package/src/pages/cart/__tests__/checkout-form.test.tsx +4 -2
- package/src/api-manager/datasources/d365/utils/decode-jwt.ts +0 -10
- package/src/auth/auth-options.test.ts +0 -70
- package/src/auth/auth-options.ts +0 -101
- package/src/auth/next-auth-cookie-manager.ts +0 -87
- package/src/auth/providers/aadb2c-provider.ts +0 -32
- package/src/auth/providers/authentik-provider.ts +0 -24
- package/src/auth/providers/entra-external-id-provider.ts +0 -50
- package/src/auth/providers/index.ts +0 -16
- package/src/auth/providers/keycloak-provider.ts +0 -22
- package/src/auth/refresh-token.ts +0 -132
- package/src/auth/types/next-auth.d.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evenicanpm/storefront-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Core module for D365/e4 Headless Storefront",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@msdyn365-commerce/retail-proxy": "9.50",
|
|
34
34
|
"@tanstack/react-query": "^5.76.1",
|
|
35
|
+
"better-auth": "*",
|
|
35
36
|
"graphql": "^16.11.0",
|
|
36
37
|
"graphql-request": "^7.1.2",
|
|
37
38
|
"jsonwebtoken": "^9.0.2",
|
|
38
39
|
"lodash": "^4.17.21",
|
|
39
40
|
"next": "^15.2.1",
|
|
40
|
-
"next-auth": "^4.24.11",
|
|
41
41
|
"object-mapper": "^6.2.0",
|
|
42
|
+
"set-cookie-parser": "^2.7.1",
|
|
42
43
|
"typedoc": "^0.28.9",
|
|
43
44
|
"typedoc-plugin-merge-modules": "^7.0.0",
|
|
44
45
|
"typescript": "^5.8.3",
|
|
@@ -49,5 +50,5 @@
|
|
|
49
50
|
"esbuild": "0.19.11"
|
|
50
51
|
}
|
|
51
52
|
},
|
|
52
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "21ad4044713e4d6625e30c745dd6cd5c7c52b0b3"
|
|
53
54
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import type { ICallerContext } from "@msdyn365-commerce/retail-proxy";
|
|
3
|
-
import { cookies } from "next/headers";
|
|
3
|
+
import { cookies, headers as getHeaders } from "next/headers";
|
|
4
4
|
import { getLocale } from "next-intl/server";
|
|
5
|
-
import {
|
|
5
|
+
import { auth } from "@/auth/better-auth";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Retrieves the D365 context from the "D365Context" cookie, enriches it with the current session token,
|
|
@@ -21,14 +21,16 @@ import { manuallyRetrieveSessionCookie } from "@/auth/next-auth-cookie-manager";
|
|
|
21
21
|
export async function getContextFromCookie(): Promise<ICallerContext> {
|
|
22
22
|
const cookieStore = await cookies();
|
|
23
23
|
const d365Context = cookieStore.get("D365Context");
|
|
24
|
-
const
|
|
24
|
+
const session = await auth.api.getSession({ headers: await getHeaders() });
|
|
25
|
+
const accessToken =
|
|
26
|
+
(session?.session as { accessToken?: string })?.accessToken || "";
|
|
25
27
|
const locale = await getLocale();
|
|
26
28
|
if (d365Context) {
|
|
27
29
|
const context = JSON.parse(d365Context.value) as ICallerContext;
|
|
28
30
|
context.requestContext.operationId = crypto.randomUUID();
|
|
29
31
|
context.requestContext.user = {
|
|
30
|
-
isAuthenticated: !!
|
|
31
|
-
token:
|
|
32
|
+
isAuthenticated: !!accessToken,
|
|
33
|
+
token: accessToken,
|
|
32
34
|
};
|
|
33
35
|
context.requestContext.locale = locale;
|
|
34
36
|
return context;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import createGraphQLClient from "@evenicanpm/storefront-core/src/lib/create-graphql-client";
|
|
2
|
+
import type { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { parse, splitCookiesString } from "set-cookie-parser";
|
|
4
|
+
|
|
5
|
+
const setE4SessionCookies = async (
|
|
6
|
+
response: NextResponse,
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
) => {
|
|
9
|
+
const cookiesAlreadySet = request.cookies.get("storefront");
|
|
10
|
+
if (cookiesAlreadySet) return response;
|
|
11
|
+
|
|
12
|
+
const e4Api = await createGraphQLClient();
|
|
13
|
+
|
|
14
|
+
const session = await e4Api.e4ReadSessionCustomer();
|
|
15
|
+
|
|
16
|
+
const cookieString = session?.headers?.get("set-cookie");
|
|
17
|
+
if (!cookieString) return response;
|
|
18
|
+
const splitCookies = parse(
|
|
19
|
+
splitCookiesString(decodeURIComponent(cookieString)),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
splitCookies.forEach((cookie) => {
|
|
23
|
+
response.cookies.set({
|
|
24
|
+
name: cookie.name,
|
|
25
|
+
value: cookie.value,
|
|
26
|
+
httpOnly: true,
|
|
27
|
+
expires: cookie.expires,
|
|
28
|
+
secure: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return response;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default setE4SessionCookies;
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { getSdk } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
|
|
4
4
|
import { GraphQLClient } from "graphql-request";
|
|
5
|
-
import { cookies } from "next/headers";
|
|
6
|
-
import {
|
|
7
|
-
import authOptions from "@/auth/auth-options";
|
|
5
|
+
import { cookies, headers as getHeaders } from "next/headers";
|
|
6
|
+
import { auth } from "@/auth/better-auth";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Initializes and returns a cached GraphQL client
|
|
@@ -28,9 +27,11 @@ const getGraphQLClient = async (withAuth = false) => {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
if (withAuth) {
|
|
31
|
-
const session = await
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const session = await auth.api.getSession({ headers: await getHeaders() });
|
|
31
|
+
const accessToken = (session?.session as { accessToken?: string })
|
|
32
|
+
?.accessToken;
|
|
33
|
+
if (accessToken) {
|
|
34
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -7,14 +7,9 @@ import {
|
|
|
7
7
|
useQuery,
|
|
8
8
|
useSuspenseQuery,
|
|
9
9
|
} from "@tanstack/react-query";
|
|
10
|
-
import { Mutex } from "async-mutex";
|
|
11
|
-
import jwt from "jsonwebtoken";
|
|
12
|
-
import { getSession } from "next-auth/react";
|
|
13
|
-
import { manuallyRetrieveSessionCookie } from "@/auth/next-auth-cookie-manager";
|
|
14
10
|
import signOut from "@/auth/signout";
|
|
15
11
|
|
|
16
12
|
const QUERY_ROUTE = "/api";
|
|
17
|
-
const mutex = new Mutex();
|
|
18
13
|
|
|
19
14
|
export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
|
|
20
15
|
T,
|
|
@@ -27,22 +22,6 @@ export type Query<TInput, TOutput> = ReturnType<
|
|
|
27
22
|
|
|
28
23
|
// --- Shared helpers (no closure dependencies) ---
|
|
29
24
|
|
|
30
|
-
const checkTokenExpiry = async (): Promise<boolean> => {
|
|
31
|
-
let token: string | undefined;
|
|
32
|
-
if (isServer) {
|
|
33
|
-
const session = (await manuallyRetrieveSessionCookie()) ?? undefined;
|
|
34
|
-
token = session?.accessToken;
|
|
35
|
-
} else {
|
|
36
|
-
const session = await getSession();
|
|
37
|
-
token = session?.accessToken;
|
|
38
|
-
}
|
|
39
|
-
const decoded = token && (jwt.decode(token) as { exp?: number } | null);
|
|
40
|
-
const currentTime = Math.floor(Date.now() / 1000);
|
|
41
|
-
return (
|
|
42
|
-
!!decoded && typeof decoded.exp === "number" && decoded.exp < currentTime
|
|
43
|
-
);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
25
|
const doFetch = async <TInput>(
|
|
47
26
|
input: TInput | undefined,
|
|
48
27
|
apiManagerPath: ApiPath,
|
|
@@ -60,27 +39,6 @@ const doFetch = async <TInput>(
|
|
|
60
39
|
return result.json();
|
|
61
40
|
};
|
|
62
41
|
|
|
63
|
-
// Acquires a mutex before fetching so concurrent requests don't each trigger
|
|
64
|
-
// a token refresh simultaneously (double-check pattern after acquiring lock).
|
|
65
|
-
const withTokenRefresh = async <TInput>(
|
|
66
|
-
input: TInput | undefined,
|
|
67
|
-
apiManagerPath: ApiPath,
|
|
68
|
-
) => {
|
|
69
|
-
const release = await mutex.acquire();
|
|
70
|
-
try {
|
|
71
|
-
const headers = new Headers();
|
|
72
|
-
if (await checkTokenExpiry()) {
|
|
73
|
-
headers.append("should-refresh-token", "true");
|
|
74
|
-
}
|
|
75
|
-
return await doFetch(input, apiManagerPath, headers);
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.error("Error fetching data:", error);
|
|
78
|
-
throw new Error("Failed to fetch data");
|
|
79
|
-
} finally {
|
|
80
|
-
release();
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
42
|
// --- Factory ---
|
|
85
43
|
|
|
86
44
|
export const createQuery = <TInput, TOutput>(
|
|
@@ -103,10 +61,7 @@ export const createQuery = <TInput, TOutput>(
|
|
|
103
61
|
return getApiFromPath(apiManager, apiManagerPath)(input);
|
|
104
62
|
}
|
|
105
63
|
: async () => {
|
|
106
|
-
|
|
107
|
-
return isExpired
|
|
108
|
-
? withTokenRefresh(input, apiManagerPath)
|
|
109
|
-
: doFetch(input, apiManagerPath);
|
|
64
|
+
return await doFetch(input, apiManagerPath);
|
|
110
65
|
},
|
|
111
66
|
enabled: config?.disableProperties
|
|
112
67
|
? config.disableProperties.every((prop) => !!input?.[prop])
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
import { genericOAuth, customSession } from "better-auth/plugins";
|
|
3
|
+
import { nextCookies } from "better-auth/next-js";
|
|
4
|
+
|
|
5
|
+
const tenantName = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
|
|
6
|
+
const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
|
|
7
|
+
|
|
8
|
+
export const auth = betterAuth({
|
|
9
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
10
|
+
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL,
|
|
11
|
+
session: {
|
|
12
|
+
cookieCache: {
|
|
13
|
+
enabled: true,
|
|
14
|
+
maxAge: 60 * 60, // 1 hour
|
|
15
|
+
strategy: "jwe",
|
|
16
|
+
refreshCache: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
account: {
|
|
20
|
+
storeStateStrategy: "cookie",
|
|
21
|
+
storeAccountCookie: true,
|
|
22
|
+
},
|
|
23
|
+
plugins: [
|
|
24
|
+
genericOAuth({
|
|
25
|
+
config: [
|
|
26
|
+
{
|
|
27
|
+
providerId: "entra-external-id",
|
|
28
|
+
clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "",
|
|
29
|
+
clientSecret:
|
|
30
|
+
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "",
|
|
31
|
+
discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
|
|
32
|
+
scopes: (
|
|
33
|
+
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES ||
|
|
34
|
+
"offline_access openid email profile"
|
|
35
|
+
).split(" "),
|
|
36
|
+
pkce: true,
|
|
37
|
+
prompt: "login",
|
|
38
|
+
getUserInfo: async (tokens) => {
|
|
39
|
+
// Decode the id_token to get user claims
|
|
40
|
+
const idToken = tokens.idToken;
|
|
41
|
+
if (idToken) {
|
|
42
|
+
const parts = idToken.split(".");
|
|
43
|
+
if (parts.length === 3) {
|
|
44
|
+
const payload = JSON.parse(
|
|
45
|
+
Buffer.from(parts[1], "base64url").toString(),
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
id: payload.oid || payload.sub,
|
|
49
|
+
name: payload.name,
|
|
50
|
+
email:
|
|
51
|
+
payload.email ||
|
|
52
|
+
payload.preferred_username ||
|
|
53
|
+
payload.emails?.[0],
|
|
54
|
+
emailVerified: !!payload.email_verified,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
}),
|
|
63
|
+
customSession(async ({ user, session }, ctx) => {
|
|
64
|
+
let accessToken = "";
|
|
65
|
+
try {
|
|
66
|
+
const accounts = await ctx.context.internalAdapter.findAccounts(
|
|
67
|
+
user.id,
|
|
68
|
+
);
|
|
69
|
+
const account = accounts?.find(
|
|
70
|
+
(a) => a.providerId === "entra-external-id",
|
|
71
|
+
);
|
|
72
|
+
// For Entra External ID, use id_token as the access token
|
|
73
|
+
accessToken = account?.idToken || account?.accessToken || "";
|
|
74
|
+
} catch {
|
|
75
|
+
// Account data may not be accessible in all modes
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
user,
|
|
79
|
+
session: {
|
|
80
|
+
...session,
|
|
81
|
+
accessToken,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
nextCookies(), // must be last plugin
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type Session = typeof auth.$Infer.Session;
|
package/src/auth/signout.ts
CHANGED
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
if (!response.ok) {
|
|
5
|
-
throw new Error(`Failed to fetch CSRF token: ${response.statusText}`);
|
|
6
|
-
}
|
|
7
|
-
const data: { csrfToken: string } = await response.json();
|
|
8
|
-
return data.csrfToken;
|
|
9
|
-
} catch (error) {
|
|
10
|
-
console.error("Error fetching CSRF token:", error);
|
|
11
|
-
throw error;
|
|
12
|
-
}
|
|
13
|
-
};
|
|
1
|
+
import { createAuthClient } from "better-auth/react";
|
|
2
|
+
|
|
3
|
+
const authClient = createAuthClient();
|
|
14
4
|
|
|
15
5
|
/**
|
|
16
|
-
*
|
|
17
|
-
* Solution to known next-auth issue - https://github.com/nextauthjs/next-auth/issues/4612
|
|
6
|
+
* Signs the user out and redirects to the home page.
|
|
18
7
|
*/
|
|
19
8
|
const signOut = async (): Promise<void> => {
|
|
20
9
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const formData = new URLSearchParams({
|
|
24
|
-
csrfToken,
|
|
25
|
-
json: "true",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const response = await fetch("/api/auth/signout", {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: {
|
|
31
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
32
|
-
},
|
|
33
|
-
body: formData,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
if (!response.ok) {
|
|
37
|
-
throw new Error(`Failed to sign out: ${response.statusText}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
10
|
+
await authClient.signOut();
|
|
40
11
|
globalThis.location.href = "/";
|
|
41
12
|
} catch (error) {
|
|
42
13
|
console.error(error);
|
|
@@ -7,8 +7,15 @@ vi.mock("@evenicanpm/storefront-core/src/hooks/use-nextauth-session", () => ({
|
|
|
7
7
|
useAppSession: vi.fn(() => ({ session: null })),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
vi.mock("
|
|
11
|
-
|
|
10
|
+
vi.mock("better-auth/react", () => ({
|
|
11
|
+
createAuthClient: () => ({
|
|
12
|
+
useSession: () => ({ data: null, isPending: false }),
|
|
13
|
+
signIn: { oauth2: vi.fn() },
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("better-auth/client/plugins", () => ({
|
|
18
|
+
genericOAuthClient: () => ({}),
|
|
12
19
|
}));
|
|
13
20
|
|
|
14
21
|
vi.mock(
|
|
@@ -4,6 +4,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
4
4
|
import { NextIntlClientProvider } from "next-intl";
|
|
5
5
|
import { vi } from "vitest";
|
|
6
6
|
|
|
7
|
+
const { mockMutate, mockHandleRedirectPromise, mockUseSession } = vi.hoisted(
|
|
8
|
+
() => ({
|
|
9
|
+
mockMutate: vi.fn().mockResolvedValue({}),
|
|
10
|
+
mockHandleRedirectPromise: vi.fn().mockResolvedValue(null),
|
|
11
|
+
mockUseSession: vi.fn(),
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
|
|
7
15
|
vi.mock(
|
|
8
16
|
"@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init",
|
|
9
17
|
() => ({
|
|
@@ -11,10 +19,6 @@ vi.mock(
|
|
|
11
19
|
}),
|
|
12
20
|
);
|
|
13
21
|
|
|
14
|
-
const mockMutate = vi.fn().mockResolvedValue({});
|
|
15
|
-
const mockHandleRedirectPromise = vi.fn().mockResolvedValue(null);
|
|
16
|
-
const mockUseSession = vi.fn();
|
|
17
|
-
|
|
18
22
|
vi.mock(
|
|
19
23
|
"@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user",
|
|
20
24
|
() => ({
|
|
@@ -36,9 +40,17 @@ vi.mock("next/navigation", () => ({
|
|
|
36
40
|
usePathname: () => "/mock-path",
|
|
37
41
|
}));
|
|
38
42
|
|
|
39
|
-
vi.
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
vi.mock("better-auth/react", () => {
|
|
44
|
+
return {
|
|
45
|
+
createAuthClient: () => ({
|
|
46
|
+
useSession: mockUseSession,
|
|
47
|
+
signIn: { oauth2: vi.fn() },
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
vi.mock("better-auth/client/plugins", () => ({
|
|
53
|
+
genericOAuthClient: () => ({}),
|
|
42
54
|
}));
|
|
43
55
|
|
|
44
56
|
vi.mock("@evenicanpm/storefront-core/src/auth/msal", () => ({
|
|
@@ -58,7 +70,7 @@ describe.skip("User Component", async () => {
|
|
|
58
70
|
it("renders login button when user is not authenticated", () => {
|
|
59
71
|
mockUseSession.mockReturnValue({
|
|
60
72
|
data: null,
|
|
61
|
-
|
|
73
|
+
isPending: false,
|
|
62
74
|
});
|
|
63
75
|
render(
|
|
64
76
|
<NextIntlClientProvider locale="en" messages={messages}>
|
|
@@ -75,9 +87,9 @@ describe.skip("User Component", async () => {
|
|
|
75
87
|
mockUseSession.mockReturnValue({
|
|
76
88
|
data: {
|
|
77
89
|
user: { email: "test@example.com" },
|
|
78
|
-
accessToken: "mockAccessToken",
|
|
90
|
+
session: { accessToken: "mockAccessToken" },
|
|
79
91
|
},
|
|
80
|
-
|
|
92
|
+
isPending: false,
|
|
81
93
|
});
|
|
82
94
|
|
|
83
95
|
render(
|
|
@@ -94,10 +106,10 @@ describe.skip("User Component", async () => {
|
|
|
94
106
|
expect(userMenuButton).toBeInTheDocument();
|
|
95
107
|
});
|
|
96
108
|
|
|
97
|
-
it("calls signOut when session
|
|
109
|
+
it("calls signOut when session is null after pending", () => {
|
|
98
110
|
mockUseSession.mockReturnValue({
|
|
99
|
-
data:
|
|
100
|
-
|
|
111
|
+
data: null,
|
|
112
|
+
isPending: false,
|
|
101
113
|
});
|
|
102
114
|
|
|
103
115
|
render(
|
|
@@ -113,8 +125,11 @@ describe.skip("User Component", async () => {
|
|
|
113
125
|
const mockSignOut = vi.mocked(signOut);
|
|
114
126
|
|
|
115
127
|
mockUseSession.mockReturnValue({
|
|
116
|
-
data: {
|
|
117
|
-
|
|
128
|
+
data: {
|
|
129
|
+
user: { email: "test@example.com" },
|
|
130
|
+
session: { accessToken: "token" },
|
|
131
|
+
},
|
|
132
|
+
isPending: false,
|
|
118
133
|
});
|
|
119
134
|
|
|
120
135
|
render(
|
|
@@ -139,9 +154,9 @@ describe.skip("User Component", async () => {
|
|
|
139
154
|
mockUseSession.mockReturnValue({
|
|
140
155
|
data: {
|
|
141
156
|
user: { email: "test@example.com" },
|
|
142
|
-
accessToken: "mockAccessToken",
|
|
157
|
+
session: { accessToken: "mockAccessToken" },
|
|
143
158
|
},
|
|
144
|
-
|
|
159
|
+
isPending: false,
|
|
145
160
|
});
|
|
146
161
|
|
|
147
162
|
vi.mocked(mockHandleRedirectPromise).mockResolvedValue({
|
|
@@ -193,9 +208,9 @@ describe.skip("User Component", async () => {
|
|
|
193
208
|
mockUseSession.mockReturnValue({
|
|
194
209
|
data: {
|
|
195
210
|
user: { email: "test@example.com" },
|
|
196
|
-
accessToken: "mockAccessToken",
|
|
211
|
+
session: { accessToken: "mockAccessToken" },
|
|
197
212
|
},
|
|
198
|
-
|
|
213
|
+
isPending: false,
|
|
199
214
|
});
|
|
200
215
|
|
|
201
216
|
vi.mocked(mockHandleRedirectPromise).mockRejectedValue(
|
|
@@ -23,7 +23,13 @@ import {
|
|
|
23
23
|
} from "@mui/material";
|
|
24
24
|
import { useRouter } from "next/navigation";
|
|
25
25
|
// Auth components
|
|
26
|
-
import {
|
|
26
|
+
import { createAuthClient } from "better-auth/react";
|
|
27
|
+
import { genericOAuthClient } from "better-auth/client/plugins";
|
|
28
|
+
|
|
29
|
+
const authClient = createAuthClient({
|
|
30
|
+
plugins: [genericOAuthClient()],
|
|
31
|
+
});
|
|
32
|
+
const { useSession: useBetterAuthSession } = authClient;
|
|
27
33
|
import type React from "react";
|
|
28
34
|
import {
|
|
29
35
|
createContext,
|
|
@@ -52,7 +58,7 @@ interface UserContextValue {
|
|
|
52
58
|
status: string;
|
|
53
59
|
menuItems: typeof MENU_ITEMS;
|
|
54
60
|
handleLogout: () => Promise<void>;
|
|
55
|
-
|
|
61
|
+
handleSignIn: () => void;
|
|
56
62
|
signOut: () => Promise<void>;
|
|
57
63
|
anchorEl: HTMLButtonElement | null;
|
|
58
64
|
setAnchorEl: (el: HTMLButtonElement | null) => void;
|
|
@@ -66,12 +72,15 @@ interface Props {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const User = ({ children }: Props) => {
|
|
69
|
-
const { data: session,
|
|
75
|
+
const { data: session, isPending } = useBetterAuthSession();
|
|
76
|
+
const status = isPending
|
|
77
|
+
? "loading"
|
|
78
|
+
: session
|
|
79
|
+
? "authenticated"
|
|
80
|
+
: "unauthenticated";
|
|
70
81
|
|
|
71
|
-
if (session
|
|
72
|
-
//
|
|
73
|
-
console.warn("Auth Error", session?.error);
|
|
74
|
-
signOutCustom();
|
|
82
|
+
if (session === null && !isPending) {
|
|
83
|
+
// No error handling needed - session is simply null when not authenticated
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const isAuthenticated = !!session;
|
|
@@ -81,12 +90,12 @@ const User = ({ children }: Props) => {
|
|
|
81
90
|
|
|
82
91
|
// session init
|
|
83
92
|
useEffect(() => {
|
|
84
|
-
if (isAuthenticated) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
if (isAuthenticated && session?.user?.email) {
|
|
94
|
+
const accessToken =
|
|
95
|
+
(session.session as { accessToken?: string })?.accessToken || "";
|
|
96
|
+
sessionInit({ accessToken, email: session.user.email }).catch(
|
|
97
|
+
console.error,
|
|
98
|
+
);
|
|
90
99
|
}
|
|
91
100
|
}, [isAuthenticated, session, sessionInit]);
|
|
92
101
|
|
|
@@ -124,6 +133,13 @@ const User = ({ children }: Props) => {
|
|
|
124
133
|
}
|
|
125
134
|
};
|
|
126
135
|
|
|
136
|
+
const handleSignIn = () => {
|
|
137
|
+
authClient.signIn.oauth2({
|
|
138
|
+
providerId: "entra-external-id",
|
|
139
|
+
callbackURL: "/",
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
127
143
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
|
128
144
|
const open = Boolean(anchorEl);
|
|
129
145
|
|
|
@@ -132,7 +148,7 @@ const User = ({ children }: Props) => {
|
|
|
132
148
|
status,
|
|
133
149
|
menuItems: MENU_ITEMS,
|
|
134
150
|
handleLogout,
|
|
135
|
-
|
|
151
|
+
handleSignIn,
|
|
136
152
|
signOut: signOutCustom,
|
|
137
153
|
anchorEl,
|
|
138
154
|
setAnchorEl,
|
|
@@ -149,13 +165,13 @@ User.MobileTrigger = function MobileTrigger() {
|
|
|
149
165
|
const ctx = useContext(UserContext);
|
|
150
166
|
const router = useRouter();
|
|
151
167
|
if (!ctx) return null;
|
|
152
|
-
const { isAuthenticated,
|
|
168
|
+
const { isAuthenticated, handleSignIn } = ctx;
|
|
153
169
|
return (
|
|
154
170
|
<IconButton
|
|
155
171
|
aria-label="User Menu"
|
|
156
172
|
onClick={() => {
|
|
157
173
|
if (isAuthenticated) router.push("/account/profile");
|
|
158
|
-
else
|
|
174
|
+
else handleSignIn();
|
|
159
175
|
}}
|
|
160
176
|
>
|
|
161
177
|
<MobileUserIcon style={{ fontSize: "1.25rem" }} />
|
|
@@ -239,13 +255,13 @@ User.MenuItem = function MenuItem({
|
|
|
239
255
|
User.LoginButton = function LoginButton() {
|
|
240
256
|
const ctx = useContext(UserContext);
|
|
241
257
|
if (!ctx) return null;
|
|
242
|
-
const {
|
|
258
|
+
const { handleSignIn } = ctx;
|
|
243
259
|
return (
|
|
244
260
|
<Button
|
|
245
261
|
size="small"
|
|
246
262
|
variant="contained"
|
|
247
263
|
color="primary"
|
|
248
|
-
onClick={() =>
|
|
264
|
+
onClick={() => handleSignIn()}
|
|
249
265
|
>
|
|
250
266
|
<T path="Account.Menu.btnLogin" />
|
|
251
267
|
</Button>
|
|
@@ -8,9 +8,11 @@ import { AddToWishListDialogContext } from "@evenicanpm/storefront-core/src/comp
|
|
|
8
8
|
// LOCAL COMPONENTS
|
|
9
9
|
import DialogHeader from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header";
|
|
10
10
|
import { Dialog, DialogContent } from "@mui/material";
|
|
11
|
-
import {
|
|
11
|
+
import { createAuthClient } from "better-auth/react";
|
|
12
12
|
import type React from "react";
|
|
13
13
|
|
|
14
|
+
const { useSession } = createAuthClient();
|
|
15
|
+
|
|
14
16
|
export interface AddToWishlistDialogProps {
|
|
15
17
|
open: boolean;
|
|
16
18
|
handleClose: () => void;
|
|
@@ -9,8 +9,10 @@ import { H2 } from "@evenicanpm/storefront-core/src/components/Typography";
|
|
|
9
9
|
import CreateWishlistButton from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/create-wishlist/create-wishlist-button";
|
|
10
10
|
import { IconButton, styled, type Theme } from "@mui/material";
|
|
11
11
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
12
|
-
import {
|
|
12
|
+
import { createAuthClient } from "better-auth/react";
|
|
13
13
|
import { useTranslations } from "next-intl";
|
|
14
|
+
|
|
15
|
+
const { useSession } = createAuthClient();
|
|
14
16
|
// ICONS
|
|
15
17
|
import { MdFavorite } from "react-icons/md";
|
|
16
18
|
|
|
@@ -1,36 +1,31 @@
|
|
|
1
1
|
// hooks/useAppSession.ts
|
|
2
2
|
|
|
3
3
|
import useSessionInit from "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init";
|
|
4
|
-
import useSessionLogout from "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-logout";
|
|
5
4
|
// API hooks
|
|
6
5
|
import useUpdateUser from "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user";
|
|
7
6
|
import {
|
|
8
7
|
getMsalInstance,
|
|
9
8
|
initializeMsal,
|
|
10
9
|
} from "@evenicanpm/storefront-core/src/auth/msal";
|
|
11
|
-
import {
|
|
10
|
+
import { createAuthClient } from "better-auth/react";
|
|
12
11
|
import { useEffect } from "react";
|
|
13
12
|
|
|
13
|
+
const authClient = createAuthClient();
|
|
14
|
+
const { useSession: useBetterAuthSession } = authClient;
|
|
15
|
+
|
|
14
16
|
export function useAppSession() {
|
|
15
|
-
const { data: session } =
|
|
17
|
+
const { data: session, isPending } = useBetterAuthSession();
|
|
16
18
|
const { mutateAsync: sessionInit } = useSessionInit();
|
|
17
19
|
const { mutateAsync: updateUser } = useUpdateUser();
|
|
18
|
-
const { mutateAsync: sessionLogout } = useSessionLogout();
|
|
19
|
-
|
|
20
|
-
// Handle custom auth errors
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
if (session?.error) {
|
|
23
|
-
console.warn("Auth Error", session.error);
|
|
24
|
-
sessionLogout({}); // Or your custom signOut
|
|
25
|
-
}
|
|
26
|
-
}, [session, sessionLogout]);
|
|
27
20
|
|
|
28
21
|
// Initialize session in API
|
|
29
22
|
useEffect(() => {
|
|
30
23
|
if (!session) return;
|
|
31
24
|
|
|
32
|
-
const {
|
|
33
|
-
|
|
25
|
+
const { user } = session;
|
|
26
|
+
const accessToken =
|
|
27
|
+
(session.session as { accessToken?: string })?.accessToken || "";
|
|
28
|
+
if (user?.email) {
|
|
34
29
|
sessionInit({ accessToken, email: user.email }).catch(console.error);
|
|
35
30
|
}
|
|
36
31
|
}, [session, sessionInit]);
|
|
@@ -64,5 +59,6 @@ export function useAppSession() {
|
|
|
64
59
|
return {
|
|
65
60
|
session,
|
|
66
61
|
isAuthenticated: !!session,
|
|
62
|
+
isPending,
|
|
67
63
|
};
|
|
68
64
|
}
|
|
@@ -10,8 +10,10 @@ import { NextIntlClientProvider } from "next-intl";
|
|
|
10
10
|
import type React from "react";
|
|
11
11
|
import { vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
vi.mock("
|
|
14
|
-
|
|
13
|
+
vi.mock("better-auth/react", () => ({
|
|
14
|
+
createAuthClient: () => ({
|
|
15
|
+
useSession: () => ({ data: null, isPending: false }),
|
|
16
|
+
}),
|
|
15
17
|
}));
|
|
16
18
|
|
|
17
19
|
// global.fetch = vi.fn(() => Promise.resolve({ json: () => ({}) }));
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
"use server";
|
|
2
|
-
import { decode } from "next-auth/jwt";
|
|
3
|
-
|
|
4
|
-
export async function decodeToken(token: string) {
|
|
5
|
-
const secret = process.env.NEXTAUTH_SECRET; //this shouldn't be from key vault.
|
|
6
|
-
if (!secret) {
|
|
7
|
-
throw new Error("Environment variable NEXTAUTH_SECRET is not set.");
|
|
8
|
-
}
|
|
9
|
-
return decode({ token: token, secret });
|
|
10
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { Account, Session } from "next-auth";
|
|
2
|
-
import type { JWT } from "next-auth/jwt";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import authOptions, { type ExtendedJwtToken } from "@/auth/auth-options";
|
|
5
|
-
|
|
6
|
-
describe("next-auth option callbacks", () => {
|
|
7
|
-
describe("jwt callback", () => {
|
|
8
|
-
it("should store accessToken and refreshToken when account and profile are available", async () => {
|
|
9
|
-
const token: ExtendedJwtToken = {
|
|
10
|
-
accessToken: "",
|
|
11
|
-
accessTokenExpiresAt: 0,
|
|
12
|
-
provider: "azure-ad-b2c",
|
|
13
|
-
refreshTokenExpiresAt: 0,
|
|
14
|
-
exp: 0,
|
|
15
|
-
};
|
|
16
|
-
const account: Account = {
|
|
17
|
-
access_token: "testAccessToken",
|
|
18
|
-
refresh_token: "testRefreshToken",
|
|
19
|
-
expires_on: Math.floor(Date.now() / 1000) + 3600,
|
|
20
|
-
refresh_token_expires_in: 86400,
|
|
21
|
-
providerAccountId: "test-provider-account-id",
|
|
22
|
-
provider: "azure-ad-b2c",
|
|
23
|
-
type: "oauth",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
let newToken: ExtendedJwtToken | undefined;
|
|
27
|
-
if (authOptions()?.callbacks?.jwt) {
|
|
28
|
-
newToken = await authOptions().callbacks.jwt({
|
|
29
|
-
token,
|
|
30
|
-
user: { id: "123" },
|
|
31
|
-
account,
|
|
32
|
-
profile: {},
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
expect(newToken?.accessToken).toBe("testAccessToken");
|
|
37
|
-
expect(newToken?.refreshToken).toBe("testRefreshToken");
|
|
38
|
-
expect(newToken?.accessTokenExpiresAt).toBe(token.accessTokenExpiresAt); // We're not setting this or using it anymore
|
|
39
|
-
expect(newToken?.refreshTokenExpiresAt).toBe(token.refreshTokenExpiresAt); // We're not setting this or using it anymore);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("session callback", () => {
|
|
44
|
-
it("should pass accessToken and error to session", async () => {
|
|
45
|
-
const session = { expires: "", user: { name: "123" } };
|
|
46
|
-
const token: JWT = {
|
|
47
|
-
accessToken: "sessionToken",
|
|
48
|
-
accessTokenExpiresAt: 0,
|
|
49
|
-
refreshToken: "",
|
|
50
|
-
refreshTokenExpiresAt: 0,
|
|
51
|
-
error: "SomeError",
|
|
52
|
-
};
|
|
53
|
-
let newSession: Session | null;
|
|
54
|
-
if (authOptions()?.callbacks?.session) {
|
|
55
|
-
newSession = await authOptions().callbacks.session({
|
|
56
|
-
session,
|
|
57
|
-
token,
|
|
58
|
-
user: { id: "123", email: "", emailVerified: new Date(Date.now()) },
|
|
59
|
-
newSession: {},
|
|
60
|
-
trigger: "update",
|
|
61
|
-
});
|
|
62
|
-
} else {
|
|
63
|
-
newSession = null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
expect(newSession?.accessToken).toBe("sessionToken");
|
|
67
|
-
expect(newSession?.error).toBe("SomeError");
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
});
|
package/src/auth/auth-options.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { deleteCartCookie } from "@evenicanpm/storefront-core/src/lib/cart-cookie-handler";
|
|
2
|
-
import type {
|
|
3
|
-
GetServerSidePropsContext,
|
|
4
|
-
NextApiRequest,
|
|
5
|
-
NextApiResponse,
|
|
6
|
-
} from "next";
|
|
7
|
-
import type { NextAuthOptions, Session } from "next-auth";
|
|
8
|
-
import type { JWT } from "next-auth/jwt";
|
|
9
|
-
import { getServerSession } from "next-auth/next";
|
|
10
|
-
import { getProviders } from "@/auth/providers";
|
|
11
|
-
|
|
12
|
-
// Split providers into separate array so we can iterate them and match the provider ID
|
|
13
|
-
export type ExtendedJwtToken = JWT & {
|
|
14
|
-
provider: string;
|
|
15
|
-
exp: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export type ExtendedSession = Session & { token?: JWT };
|
|
19
|
-
|
|
20
|
-
const createAuthOptions = (): NextAuthOptions => {
|
|
21
|
-
const providers = getProviders();
|
|
22
|
-
const secret = process.env.NEXTAUTH_SECRET; // internally used by next-auth, shouldn't be from key vault.
|
|
23
|
-
return {
|
|
24
|
-
providers,
|
|
25
|
-
secret,
|
|
26
|
-
cookies: {
|
|
27
|
-
csrfToken: {
|
|
28
|
-
name: "next-auth.csrf-token",
|
|
29
|
-
options: {
|
|
30
|
-
httpOnly: true,
|
|
31
|
-
sameSite: "none",
|
|
32
|
-
path: "/",
|
|
33
|
-
secure: true,
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
pkceCodeVerifier: {
|
|
37
|
-
name: "next-auth.pkce.code_verifier",
|
|
38
|
-
options: {
|
|
39
|
-
httpOnly: true,
|
|
40
|
-
sameSite: "none",
|
|
41
|
-
path: "/",
|
|
42
|
-
secure: true,
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
nonce: {
|
|
46
|
-
name: "next-auth.nonce",
|
|
47
|
-
options: {
|
|
48
|
-
httpOnly: true,
|
|
49
|
-
sameSite: "none",
|
|
50
|
-
path: "/",
|
|
51
|
-
secure: true,
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
// Order in which callbacks are called = signin > redirect > jwt > session. Thats why session callback has token info.
|
|
56
|
-
callbacks: {
|
|
57
|
-
// By default, NextAuth uses "session" with strategy: "jwt"
|
|
58
|
-
async jwt({ token, account, profile }) {
|
|
59
|
-
const _token = token as ExtendedJwtToken;
|
|
60
|
-
// account and profile is available only in first callback after signin
|
|
61
|
-
if (account && profile) {
|
|
62
|
-
_token.provider = account.provider;
|
|
63
|
-
// use id_token for entra external id and access_token for aadb2c
|
|
64
|
-
_token.accessToken =
|
|
65
|
-
_token.provider === "entra-external-id"
|
|
66
|
-
? account.id_token
|
|
67
|
-
: account.access_token;
|
|
68
|
-
_token.refreshToken = account.refresh_token;
|
|
69
|
-
}
|
|
70
|
-
return _token;
|
|
71
|
-
// return await checkAccessTokenExpiry(_token)
|
|
72
|
-
},
|
|
73
|
-
async session({ session, token }) {
|
|
74
|
-
const _session = { ...session } as ExtendedSession;
|
|
75
|
-
// Way to pass data to the browser
|
|
76
|
-
// Use this error to force logout in UI. No proper way to signout user from server.
|
|
77
|
-
_session.error = token?.error;
|
|
78
|
-
_session.accessToken = token.accessToken;
|
|
79
|
-
|
|
80
|
-
return _session;
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
events: {
|
|
84
|
-
signOut: async () => {
|
|
85
|
-
await deleteCartCookie();
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export function auth(
|
|
92
|
-
// <-- use this function to access the jwt from React components
|
|
93
|
-
...args:
|
|
94
|
-
| [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]]
|
|
95
|
-
| [NextApiRequest, NextApiResponse]
|
|
96
|
-
| []
|
|
97
|
-
) {
|
|
98
|
-
return getServerSession(...args, createAuthOptions());
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export default createAuthOptions;
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"use server";
|
|
2
|
-
import type { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies";
|
|
3
|
-
import { cookies } from "next/headers";
|
|
4
|
-
import { decode, encode, type JWT } from "next-auth/jwt";
|
|
5
|
-
import type { ExtendedJwtToken } from "@/auth/auth-options";
|
|
6
|
-
|
|
7
|
-
const NEXT_AUTH_COOKIE_NAME = "next-auth.session-token";
|
|
8
|
-
const SECURE_COOKIE_PREFIX = "__Secure-";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Retrieves and decodes the NextAuth session cookie from the request.
|
|
12
|
-
*
|
|
13
|
-
* This function handles both single and chunked session cookies:
|
|
14
|
-
* - If the session cookie is under 4KB, it is stored as a single cookie.
|
|
15
|
-
* - If the session cookie exceeds 4KB, it is split into multiple chunked cookies with indexed names.
|
|
16
|
-
*
|
|
17
|
-
* The function attempts to retrieve the single cookie first. If not found,
|
|
18
|
-
* it iteratively collects all chunked cookies, concatenates their values,
|
|
19
|
-
* and decodes the resulting token.
|
|
20
|
-
*
|
|
21
|
-
* @returns A Promise resolving to the decoded session object, or `undefined` if no session cookie is found.
|
|
22
|
-
*/
|
|
23
|
-
const manuallyRetrieveSessionCookie = async () => {
|
|
24
|
-
const cookieStore = await cookies();
|
|
25
|
-
let cookie: RequestCookie | undefined;
|
|
26
|
-
// Single token when the cookie size is < 4k
|
|
27
|
-
cookie = cookieStore.get(`${SECURE_COOKIE_PREFIX}${NEXT_AUTH_COOKIE_NAME}`);
|
|
28
|
-
//If the cookie is found, return it. the cookie is under 4kb
|
|
29
|
-
if (cookie) {
|
|
30
|
-
return decode({
|
|
31
|
-
token: cookie.value,
|
|
32
|
-
secret: process.env.NEXTAUTH_SECRET || "",
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
//If the cookie is not found, we need to see if there is a chunked cookie
|
|
36
|
-
//The chunked cookie is stored in multiple cookies with the same name and an index
|
|
37
|
-
//The cookie is chunked when the cookie size is > 4k
|
|
38
|
-
//The value is just split between the cookies, so we can just concat them.
|
|
39
|
-
let i = 0;
|
|
40
|
-
const cookieValues: string[] = [];
|
|
41
|
-
do {
|
|
42
|
-
cookie = cookieStore.get(
|
|
43
|
-
`${SECURE_COOKIE_PREFIX}${NEXT_AUTH_COOKIE_NAME}.${i}`,
|
|
44
|
-
);
|
|
45
|
-
if (cookie) {
|
|
46
|
-
cookieValues.push(cookie.value);
|
|
47
|
-
}
|
|
48
|
-
i++;
|
|
49
|
-
} while (cookie !== undefined);
|
|
50
|
-
if (cookieValues.length) {
|
|
51
|
-
return decode({
|
|
52
|
-
token: cookieValues.join(""),
|
|
53
|
-
secret: process.env.NEXTAUTH_SECRET || "",
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
//If no cookie is found, return undefined
|
|
58
|
-
return undefined;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Manually sets the NextAuth session cookie with the provided JWT token.
|
|
63
|
-
*
|
|
64
|
-
* This function encodes the given JWT or ExtendedJwtToken and sets it as a secure,
|
|
65
|
-
* HTTP-only cookie named `__Secure-next-auth.session-token`. The cookie is set using
|
|
66
|
-
* the current cookie store, and the encoding uses the secret from the `NEXTAUTH_SECRET`
|
|
67
|
-
* environment variable.
|
|
68
|
-
*
|
|
69
|
-
* @param token - The JWT or ExtendedJwtToken to be encoded and stored in the session cookie.
|
|
70
|
-
* @returns A promise that resolves when the cookie has been set.
|
|
71
|
-
*/
|
|
72
|
-
const manuallySetSessionCookie = async (token: JWT | ExtendedJwtToken) => {
|
|
73
|
-
const cookieStore = await cookies();
|
|
74
|
-
// Set the cookie with the name __Secure-next-auth.session-token
|
|
75
|
-
const value = await encode({
|
|
76
|
-
token,
|
|
77
|
-
secret: process.env.NEXTAUTH_SECRET || "",
|
|
78
|
-
});
|
|
79
|
-
cookieStore.set({
|
|
80
|
-
name: `${SECURE_COOKIE_PREFIX}${NEXT_AUTH_COOKIE_NAME}`,
|
|
81
|
-
value,
|
|
82
|
-
httpOnly: true,
|
|
83
|
-
secure: true,
|
|
84
|
-
});
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export { manuallyRetrieveSessionCookie, manuallySetSessionCookie };
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
|
2
|
-
|
|
3
|
-
// By default, NextAuth tries to use either PKCE or Client Secret-based authentication, depending on how the provider is configured.
|
|
4
|
-
// If clientSecret is provided, it assumes client secret authentication (which doesn't use code_challenge).
|
|
5
|
-
// If clientSecret is missing, it assumes PKCE.
|
|
6
|
-
// But Azure AD B2C sometimes requires PKCE explicitly, so NextAuth needs to be told to always use PKCE.
|
|
7
|
-
const azureADB2CProvider = () => {
|
|
8
|
-
const CLIENT_ID = process.env.NEXT_PUBLIC_AZURE_AD_B2C_CLIENTID || "";
|
|
9
|
-
const SCOPE = `${process.env.NEXT_PUBLIC_AZURE_AD_B2C_API_PERMISSION} offline_access openid`;
|
|
10
|
-
const LOGIN_AUTHORITY = process.env.NEXT_PUBLIC_AZURE_AD_B2C_LOGIN_AUTHORITY;
|
|
11
|
-
|
|
12
|
-
return AzureADB2CProvider({
|
|
13
|
-
clientId: CLIENT_ID,
|
|
14
|
-
clientSecret: "",
|
|
15
|
-
issuer: `${LOGIN_AUTHORITY}/v2.0`, // either (tenantId and primaryUserFlow) or issuer must be provided
|
|
16
|
-
authorization: {
|
|
17
|
-
// We can also send params from UI - signIn("azure-ad-b2c", {}, { prompt: "login" })
|
|
18
|
-
params: {
|
|
19
|
-
scope: SCOPE,
|
|
20
|
-
// When a user logs in once, the session remains active, and next time Azure AD B2C automatically logs the user in without showing the sign-in page again
|
|
21
|
-
// This option forces login screen every time.
|
|
22
|
-
prompt: "login",
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
checks: ["pkce"],
|
|
26
|
-
client: {
|
|
27
|
-
token_endpoint_auth_method: "none", // Disables Client Secret, ensuring that only PKCE is used
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export default azureADB2CProvider;
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import AuthentikProvider from "next-auth/providers/authentik";
|
|
2
|
-
|
|
3
|
-
const clientId = "DRWJuuA4HEWyhSAsDjTgUHrsDo7CmeUa1p5UVN2e";
|
|
4
|
-
const clientSecret =
|
|
5
|
-
"4PyTnKqbO6fzDngGrLkD9YiFTxXASsJH7WPoMBeFCpezfrooZE3j2FCnWYXfO5ioFm8Uwx95kHspXsNRjl9S3uq7CBZcJUpbFfXywDMEGqrxXKT4biEuiihOTHL65qOY";
|
|
6
|
-
const issuer =
|
|
7
|
-
"https://authentik-server.orangeocean-cbcb289b.eastus.azurecontainerapps.io/application/o/sandboxauthapp";
|
|
8
|
-
const wellKnown =
|
|
9
|
-
"https://authentik-server.orangeocean-cbcb289b.eastus.azurecontainerapps.io/application/o/sandboxauthapp/.well-known/openid-configuration";
|
|
10
|
-
const authentikProvider = AuthentikProvider({
|
|
11
|
-
clientId,
|
|
12
|
-
clientSecret,
|
|
13
|
-
issuer,
|
|
14
|
-
id: "Authentik",
|
|
15
|
-
name: "Authentik",
|
|
16
|
-
wellKnown: wellKnown,
|
|
17
|
-
authorization: {
|
|
18
|
-
params: {
|
|
19
|
-
scope: "openid email profile offline_access",
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export default authentikProvider;
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { Provider } from "next-auth/providers";
|
|
2
|
-
|
|
3
|
-
const createEntraExternalIdProvider = (): Provider => {
|
|
4
|
-
const clientId = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "";
|
|
5
|
-
const customScopes =
|
|
6
|
-
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES || "";
|
|
7
|
-
const tenantName =
|
|
8
|
-
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
|
|
9
|
-
const clientSecret =
|
|
10
|
-
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "";
|
|
11
|
-
|
|
12
|
-
// Build auth url and wellKnown from the loginAuthority
|
|
13
|
-
const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
|
|
14
|
-
const authUrl = `${loginAuthority}/oauth2/v2.0/authorize`;
|
|
15
|
-
const wellKnownUrl = `${loginAuthority}/v2.0/.well-known/openid-configuration`;
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
id: "entra-external-id",
|
|
19
|
-
clientId: clientId,
|
|
20
|
-
client: {
|
|
21
|
-
client_id: clientId,
|
|
22
|
-
response_types: ["code id_token"],
|
|
23
|
-
client_secret: clientSecret,
|
|
24
|
-
// token_endpoint_auth_method: 'none',
|
|
25
|
-
},
|
|
26
|
-
type: "oauth",
|
|
27
|
-
idToken: true,
|
|
28
|
-
name: "Microsoft Entra External ID",
|
|
29
|
-
authorization: {
|
|
30
|
-
url: authUrl,
|
|
31
|
-
params: {
|
|
32
|
-
scope: customScopes,
|
|
33
|
-
prompt: "login",
|
|
34
|
-
response_type: "code id_token",
|
|
35
|
-
response_mode: "form_post",
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
wellKnown: wellKnownUrl,
|
|
39
|
-
profile(profile) {
|
|
40
|
-
return {
|
|
41
|
-
id: profile.oid,
|
|
42
|
-
name: profile.name,
|
|
43
|
-
email: profile.email,
|
|
44
|
-
};
|
|
45
|
-
},
|
|
46
|
-
checks: ["pkce", "nonce"],
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export default createEntraExternalIdProvider;
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import azureADB2CProvider from "@/auth/providers/aadb2c-provider";
|
|
2
|
-
import authentikProvider from "@/auth/providers/authentik-provider";
|
|
3
|
-
import createEntraExternalIdProvider from "@/auth/providers/entra-external-id-provider";
|
|
4
|
-
import keycloakProvider from "@/auth/providers/keycloak-provider";
|
|
5
|
-
|
|
6
|
-
export const getProviders = () => {
|
|
7
|
-
const azureProvider = azureADB2CProvider();
|
|
8
|
-
const entraExternalIdProvider = createEntraExternalIdProvider();
|
|
9
|
-
// once we stop hard coding others use loader function here
|
|
10
|
-
return [
|
|
11
|
-
authentikProvider,
|
|
12
|
-
azureProvider,
|
|
13
|
-
keycloakProvider,
|
|
14
|
-
entraExternalIdProvider,
|
|
15
|
-
];
|
|
16
|
-
};
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import KeycloakProvider from "next-auth/providers/keycloak";
|
|
2
|
-
|
|
3
|
-
const clientid = "b6229a60-b0de-4f86-9891-e0b58d756e39";
|
|
4
|
-
const issuer =
|
|
5
|
-
"https://keycloak-app-g9dmfkf6atdqdrcn.eastus2-01.azurewebsites.net/realms/e4headless";
|
|
6
|
-
const wellKnown =
|
|
7
|
-
"https://keycloak-app-g9dmfkf6atdqdrcn.eastus2-01.azurewebsites.net/realms/e4headless/.well-known/openid-configuration";
|
|
8
|
-
const keycloakProvider = KeycloakProvider({
|
|
9
|
-
clientId: clientid,
|
|
10
|
-
clientSecret: "",
|
|
11
|
-
issuer: issuer,
|
|
12
|
-
id: "Keycloak",
|
|
13
|
-
name: "Keycloak",
|
|
14
|
-
wellKnown: wellKnown,
|
|
15
|
-
authorization: {
|
|
16
|
-
params: {
|
|
17
|
-
scope: "openid email profile offline_access",
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export default keycloakProvider;
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"use server";
|
|
2
|
-
import jwt from "jsonwebtoken";
|
|
3
|
-
import type { JWT } from "next-auth/jwt";
|
|
4
|
-
import type { AuthorizationEndpointHandler } from "next-auth/providers";
|
|
5
|
-
import type { ExtendedJwtToken } from "@/auth/auth-options";
|
|
6
|
-
import { getProviders } from "@/auth/providers";
|
|
7
|
-
|
|
8
|
-
type TokenResponse = {
|
|
9
|
-
token: ExtendedJwtToken;
|
|
10
|
-
isRefreshed: boolean;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const checkAccessTokenExpiry = async (
|
|
14
|
-
token: ExtendedJwtToken,
|
|
15
|
-
): Promise<TokenResponse> => {
|
|
16
|
-
const providers = getProviders();
|
|
17
|
-
const decryptedToken = jwt.decode(token?.accessToken ?? "") as jwt.JwtPayload;
|
|
18
|
-
if (!decryptedToken) {
|
|
19
|
-
token.error = "Token Invalid";
|
|
20
|
-
return { token, isRefreshed: false };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (!decryptedToken.exp) {
|
|
24
|
-
token.error = "noExpiryOnToken";
|
|
25
|
-
return {
|
|
26
|
-
token,
|
|
27
|
-
isRefreshed: false,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
const expiry = decryptedToken.exp * 1000;
|
|
31
|
-
const nowMinusBuffer = 5 * 60 * 1000; // 5 minutes
|
|
32
|
-
// Check if token is expired or will expire in 5 minutes
|
|
33
|
-
// return token if access Token is not expired yet
|
|
34
|
-
if (Date.now() < expiry - nowMinusBuffer) {
|
|
35
|
-
return {
|
|
36
|
-
token,
|
|
37
|
-
isRefreshed: false,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const refreshTokenExpiry = token.exp * 1000;
|
|
42
|
-
|
|
43
|
-
// Avoid making request for access token if refresh token is expired.
|
|
44
|
-
if (Date.now() >= refreshTokenExpiry) {
|
|
45
|
-
token.error = "RefreshTokenExpired";
|
|
46
|
-
return {
|
|
47
|
-
token,
|
|
48
|
-
isRefreshed: false,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
//Find the provider that matches the provider cookie
|
|
53
|
-
const matchedProvider = providers.find(
|
|
54
|
-
(p) => p.id.toLowerCase() === token.provider.toLowerCase(),
|
|
55
|
-
);
|
|
56
|
-
if (!matchedProvider || !("wellKnown" in matchedProvider)) {
|
|
57
|
-
token.error = "NoProviderMatch";
|
|
58
|
-
return {
|
|
59
|
-
token,
|
|
60
|
-
isRefreshed: false,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const wellKnown = matchedProvider.wellKnown;
|
|
65
|
-
const clientId = matchedProvider.options?.clientId;
|
|
66
|
-
const scope = (
|
|
67
|
-
matchedProvider.options?.authorization as AuthorizationEndpointHandler
|
|
68
|
-
)?.params?.scope;
|
|
69
|
-
|
|
70
|
-
if (!wellKnown || !clientId || !scope) {
|
|
71
|
-
return {
|
|
72
|
-
token: { ...token, error: "MissingProviderInfo" },
|
|
73
|
-
isRefreshed: false,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const newToken = await refreshAccessToken(token, wellKnown, clientId, scope);
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
token: newToken as ExtendedJwtToken,
|
|
81
|
-
isRefreshed: !newToken.error,
|
|
82
|
-
};
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const refreshAccessToken = async (
|
|
86
|
-
token: JWT,
|
|
87
|
-
wellKnown: string,
|
|
88
|
-
clientId: string,
|
|
89
|
-
scope: string,
|
|
90
|
-
) => {
|
|
91
|
-
if (!token.refreshToken) {
|
|
92
|
-
return { ...token, error: "NoRefreshToken" };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const refreshUrl = await getTokenEndpoint(wellKnown);
|
|
96
|
-
try {
|
|
97
|
-
const response = await fetch(refreshUrl, {
|
|
98
|
-
method: "POST",
|
|
99
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
100
|
-
body: new URLSearchParams({
|
|
101
|
-
client_id: clientId,
|
|
102
|
-
grant_type: "refresh_token",
|
|
103
|
-
refresh_token: token.refreshToken,
|
|
104
|
-
scope: scope,
|
|
105
|
-
}),
|
|
106
|
-
});
|
|
107
|
-
const refreshedTokens = await response.json();
|
|
108
|
-
if (!response.ok) {
|
|
109
|
-
throw new Error(refreshedTokens.error_description);
|
|
110
|
-
}
|
|
111
|
-
return {
|
|
112
|
-
...token,
|
|
113
|
-
accessToken: refreshedTokens.access_token,
|
|
114
|
-
refreshToken: refreshedTokens.refresh_token,
|
|
115
|
-
};
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error("Error refreshing web token:", error);
|
|
118
|
-
return { ...token, error: "RefreshAccessTokenError" };
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const getTokenEndpoint = async (wellKnown: string) => {
|
|
123
|
-
const json = await getWellKnownData(wellKnown);
|
|
124
|
-
return json.token_endpoint;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const getWellKnownData = async (wellKnown: string) => {
|
|
128
|
-
const data = await fetch(wellKnown);
|
|
129
|
-
return data.json();
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
export default checkAccessTokenExpiry;
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
2
|
-
import type { NextAuthOptions } from "next-auth";
|
|
3
|
-
|
|
4
|
-
declare module "next-auth" {
|
|
5
|
-
/**
|
|
6
|
-
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
|
|
7
|
-
*/
|
|
8
|
-
interface Session {
|
|
9
|
-
error?: string;
|
|
10
|
-
accessToken?: string;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
declare module "next-auth/jwt" {
|
|
15
|
-
interface JWT {
|
|
16
|
-
accessToken?: string;
|
|
17
|
-
accessTokenExpiresAt: number;
|
|
18
|
-
refreshToken?: string;
|
|
19
|
-
refreshTokenExpiresAt: number;
|
|
20
|
-
error?: string;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Usually contains information about the provider being used
|
|
26
|
-
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
|
|
27
|
-
*/
|
|
28
|
-
declare module "next-auth/providers/azure-ad-b2c" {
|
|
29
|
-
interface Account {
|
|
30
|
-
access_token: string;
|
|
31
|
-
expires_on: number;
|
|
32
|
-
refresh_token: string;
|
|
33
|
-
refresh_token_expires_in: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The OAuth profile returned from your provider */
|
|
37
|
-
// interface Profile {}
|
|
38
|
-
}
|