acinguiux-dnr-utils 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +43 -0
  3. package/package.json +57 -0
  4. package/src/application-insights/index.ts +37 -0
  5. package/src/authentication/auth.ts +59 -0
  6. package/src/authentication/callbacks.test.ts +226 -0
  7. package/src/authentication/callbacks.ts +68 -0
  8. package/src/authentication/client-auth-guard.test.ts +116 -0
  9. package/src/authentication/client-auth-guard.ts +29 -0
  10. package/src/authentication/helpers.test.ts +41 -0
  11. package/src/authentication/helpers.ts +14 -0
  12. package/src/authentication/index.ts +8 -0
  13. package/src/authentication/middleware.ts +17 -0
  14. package/src/authentication/provider.test.ts +168 -0
  15. package/src/authentication/provider.ts +56 -0
  16. package/src/authentication/services/permissions.test.ts +102 -0
  17. package/src/authentication/services/permissions.ts +52 -0
  18. package/src/formatters/index.ts +1 -0
  19. package/src/formatters/number.test.ts +49 -0
  20. package/src/formatters/number.ts +25 -0
  21. package/src/instrumentation/browser.test.ts +118 -0
  22. package/src/instrumentation/browser.ts +24 -0
  23. package/src/instrumentation/node.test.ts +63 -0
  24. package/src/instrumentation/node.ts +81 -0
  25. package/src/loggers/auth-fetch.test.ts +383 -0
  26. package/src/loggers/auth-fetch.ts +79 -0
  27. package/src/loggers/index.ts +1 -0
  28. package/src/mappers/index.ts +1 -0
  29. package/src/mappers/name.test.ts +45 -0
  30. package/src/mappers/name.ts +16 -0
  31. package/src/ms-graph/README.md +47 -0
  32. package/src/ms-graph/get-graph-token.test.ts +116 -0
  33. package/src/ms-graph/get-graph-token.ts +41 -0
  34. package/src/ms-graph/index.ts +1 -0
  35. package/src/ms-graph/user-photo.test.ts +108 -0
  36. package/src/ms-graph/user-photo.ts +45 -0
  37. package/tsconfig.json +12 -0
  38. package/types.d.ts +26 -0
  39. package/vitest.config.js +20 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # acinguiux-dnr-utils
2
+
3
+ ## 1.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1688](https://github.com/sede-enterprise/dnr-ts-geneva-ui/pull/1688) [`427424e`](https://github.com/sede-enterprise/dnr-ts-geneva-ui/commit/427424ed9cf248f35a1861ed2d5203ff1b9253e2) Thanks [@Hasan-Haque_shell](https://github.com/Hasan-Haque_shell)! - nextjs version update
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#1297](https://github.com/sede-enterprise/dnr-ts-geneva-ui/pull/1297) [`4c2f21d`](https://github.com/sede-enterprise/dnr-ts-geneva-ui/commit/4c2f21d29ea86872f56544f93ecbacd844f200b5) Thanks [@Palash-Debnath_shell](https://github.com/Palash-Debnath_shell)! - Biome integration and eslint prettier deprecation
14
+
15
+ ## 1.0.6
16
+
17
+ ### Patch Changes
18
+
19
+ - [#1529](https://github.com/sede-enterprise/dnr-ts-geneva-ui/pull/1529) [`66d3999`](https://github.com/sede-enterprise/dnr-ts-geneva-ui/commit/66d3999248f3adaa76b3d748bf29b6de60a954e5) Thanks [@Palash-Debnath_shell](https://github.com/Palash-Debnath_shell)! - Dependency upgrade
20
+
21
+ ## 1.0.5
22
+
23
+ ### Patch Changes
24
+
25
+ - [#1394](https://github.com/sede-enterprise/dnr-ts-geneva-ui/pull/1394) [`baa69e3`](https://github.com/sede-enterprise/dnr-ts-geneva-ui/commit/baa69e3524f279871b5ebb299baa7dab3200b282) Thanks [@github-actions](https://github.com/apps/github-actions)! - Automated patch version bump for changed packages.
26
+
27
+ ## 1.0.4
28
+
29
+ ### Patch Changes
30
+
31
+ - [#1204](https://github.com/sede-enterprise/dnr-ts-geneva-ui/pull/1204) [`105466e`](https://github.com/sede-enterprise/dnr-ts-geneva-ui/commit/105466ed9f62390f75460a6ab0f97caeb3842f1e) Thanks [@github-actions](https://github.com/apps/github-actions)! - Automated patch version bump for changed packages.
32
+
33
+ ## 1.0.3
34
+
35
+ ### Patch Changes
36
+
37
+ - [#941](https://github.com/sede-enterprise/geneva-ui/pull/941) [`eeda330`](https://github.com/sede-enterprise/geneva-ui/commit/eeda330b4ef7823c517748fa3ee045d6f49f4da5) Thanks [@github-actions](https://github.com/apps/github-actions)! - Automated patch version bump for changed packages.
38
+
39
+ ## 1.0.2
40
+
41
+ ### Patch Changes
42
+
43
+ - [#932](https://github.com/sede-enterprise/geneva-ui/pull/932) [`5524022`](https://github.com/sede-enterprise/geneva-ui/commit/55240229cb5e483683bf8569df56b9c48378c593) Thanks [@PalashDebnath](https://github.com/PalashDebnath)! - update package
44
+
45
+ ## 1.0.1
46
+
47
+ ### Patch Changes
48
+
49
+ - [#801](https://github.com/sede-enterprise/geneva-ui/pull/801) [`1f81db1`](https://github.com/sede-enterprise/geneva-ui/commit/1f81db1ae464635954f5b548b18b6a4e31a87047) Thanks [@maslianok](https://github.com/maslianok)! - Update next.js to 14.2.25
50
+
51
+ ## 1.0.0
52
+
53
+ ### Major Changes
54
+
55
+ - [#233](https://github.com/sede-enterprise/geneva-ui/pull/233) [`f694180`](https://github.com/sede-enterprise/geneva-ui/commit/f69418071be8840acaaf821cc351445cba755c14) Thanks [@billcreative](https://github.com/billcreative)! - geneva-ui/feat/update-next-auth
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ ## Geneva Utils
2
+
3
+ ### Overview
4
+
5
+ The Geneva Utils package is designed to provide a collection of reusable functions that can be used across various Geneva Frontend applications. These functions aim to enhance the development process and improve code efficiency by offering commonly used functionalities in a modular and organized manner.
6
+
7
+ ### Integrate User Authorisation with monorepo App
8
+
9
+ We get user authorisation from a different endpoint from authentication. PingID is used for authentication and for authorisation we use https://ted.shell.com/
10
+
11
+ For a user getUserPermissions service can be used to fetch Authorisation from Ted
12
+
13
+ The authorisation needs an environment variables to work with.
14
+
15
+ AUTHORISATION_URL="https://ted-api-\*.shell.com"
16
+
17
+ Without this variable it is assumed the application is not setup for user Authorisation and it will not fetch user permission from Ted endpoints.
18
+
19
+ The getUserPermissions service is always cached and revalidate every 15 minutes. Even if the application requests for multiple fetch it will alway return from cahce until expires;
20
+
21
+ From the application layout it can be integrated like
22
+
23
+ ```
24
+ const userRoles = await getUserPermissions('PLATO');
25
+
26
+ const hasAccess = !!(userRoles?.[0]?.items?.[0].item_name === 'ALL RESOURCES');
27
+
28
+ return (
29
+ <html lang="en">
30
+ <head>
31
+ <link rel="icon" href="favicon.ico" />
32
+ <link rel="stylesheet" href="https://shell-fonts.azureedge.net/index.css" />
33
+ </head>
34
+ <body>
35
+ <Application session={session} aiConnectionString={process.env.INSIGHTS_CONNECTION_STRING} sidebar={hasAccess}>
36
+ {hasAccess ? children : <UnauthorizedAppCard title="No permissions to view Plato" />}
37
+ </Application>
38
+ </body>
39
+ </html>
40
+ );
41
+ };
42
+
43
+ ```
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "acinguiux-dnr-utils",
3
+ "version": "0.0.1",
4
+ "dependencies": {
5
+ "@auth/core": "^0.41.1",
6
+ "@grafana/faro-web-sdk": "^1.19.0",
7
+ "@microsoft/applicationinsights-clickanalytics-js": "^3.3.10",
8
+ "@microsoft/applicationinsights-react-js": "^17.3.6",
9
+ "@microsoft/applicationinsights-web": "^3.3.10",
10
+ "@opentelemetry/core": "^2.2.0",
11
+ "@opentelemetry/exporter-logs-otlp-http": "^0.204.0",
12
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.202.0",
13
+ "@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
14
+ "@opentelemetry/instrumentation-http": "^0.203.0",
15
+ "@opentelemetry/instrumentation-undici": "^0.13.2",
16
+ "@opentelemetry/instrumentation-winston": "^0.50.2",
17
+ "@opentelemetry/resources": "^2.2.0",
18
+ "@opentelemetry/sdk-node": "^0.203.0",
19
+ "@opentelemetry/semantic-conventions": "^1.37.0",
20
+ "@opentelemetry/winston-transport": "^0.16.2",
21
+ "next": "14.2.35",
22
+ "winston": "^3.18.3"
23
+ },
24
+ "devDependencies": {
25
+ "acinguiux-dnr-typescript": "0.0.1",
26
+ "acinguiux-dnr-vitest": "0.0.1",
27
+ "@types/node": "^22.19.0",
28
+ "@types/react": "^18.0.0"
29
+ },
30
+ "exports": {
31
+ "./application-insights": "./src/application-insights/index.ts",
32
+ "./authentication": "./src/authentication/index.ts",
33
+ "./authentication/middleware": "./src/authentication/middleware.ts",
34
+ "./authentication/client-auth-guard": "./src/authentication/client-auth-guard.ts",
35
+ "./ms-graph": "./src/ms-graph/index.ts",
36
+ "./formatters": "./src/formatters/index.ts",
37
+ "./mappers": "./src/mappers/index.ts",
38
+ "./instrumentation/node": "./src/instrumentation/node.ts",
39
+ "./instrumentation/browser": "./src/instrumentation/browser.ts",
40
+ "./loggers": "./src/loggers/index.ts"
41
+ },
42
+ "peerDependencies": {
43
+ "next-auth": "5.0.0-beta.30",
44
+ "react": "^18.0.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "clean": "rm -rf node_modules && rm -rf .turbo",
51
+ "lint": "biome check --write",
52
+ "test": "vitest run --coverage",
53
+ "test:watch": "vitest",
54
+ "types": "tsc"
55
+ },
56
+ "type": "module"
57
+ }
@@ -0,0 +1,37 @@
1
+ import { ClickAnalyticsPlugin } from "@microsoft/applicationinsights-clickanalytics-js";
2
+ import { ReactPlugin } from "@microsoft/applicationinsights-react-js";
3
+ import { ApplicationInsights, type Snippet } from "@microsoft/applicationinsights-web";
4
+ import type { Session } from "next-auth";
5
+
6
+ export let applicationInsights: ApplicationInsights | undefined;
7
+
8
+ export const initApplicationInsights = (connectionString?: string, user?: Session["user"]) => {
9
+ if (!connectionString) return;
10
+ if (applicationInsights) return;
11
+ if (typeof window === "undefined") return;
12
+
13
+ const reactPlugin = new ReactPlugin();
14
+ const clickAnalyticsPlugin = new ClickAnalyticsPlugin();
15
+ const config: Snippet["config"] = {
16
+ connectionString,
17
+ enableCorsCorrelation: true,
18
+ enableAjaxPerfTracking: true,
19
+ enableAutoRouteTracking: false,
20
+ enableRequestHeaderTracking: true,
21
+ enableResponseHeaderTracking: true,
22
+ isBrowserLinkTrackingEnabled: true,
23
+ extensions: [reactPlugin, clickAnalyticsPlugin] as Snippet["config"]["extensions"],
24
+ extensionConfig: {
25
+ [clickAnalyticsPlugin.identifier]: { autoCapture: true },
26
+ },
27
+ };
28
+
29
+ applicationInsights = new ApplicationInsights({ config });
30
+
31
+ applicationInsights.loadAppInsights();
32
+ applicationInsights.setAuthenticatedUserContext(
33
+ user?.email || "anonymous@shell.com",
34
+ undefined,
35
+ true,
36
+ );
37
+ };
@@ -0,0 +1,59 @@
1
+ import { NextResponse } from "next/server";
2
+ import NextAuth, { type NextAuthResult } from "next-auth";
3
+ import type { Session } from "next-auth";
4
+ import { callbacks } from "./callbacks";
5
+ import PingID from "./provider";
6
+
7
+ const isAuthDisabled = ["true", "1", "yes"].includes(
8
+ (process.env.AUTH_DISABLED || "").toLowerCase(),
9
+ );
10
+
11
+ const mockSession: Session = {
12
+ user: {
13
+ name: "Local User",
14
+ email: "local.user@example.com",
15
+ },
16
+ access_token: "local-access-token",
17
+ expires_at: Math.floor(Date.now() / 1000) + 60 * 60,
18
+ expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
19
+ };
20
+
21
+ const nextAuthResult: NextAuthResult | null = isAuthDisabled
22
+ ? null
23
+ : NextAuth({
24
+ providers: [
25
+ PingID({ clientId: process.env.CLIENT_ID, baseUrl: process.env.SSO_ISSUER_URL }),
26
+ ],
27
+ pages: {
28
+ signIn: "/auth/signin",
29
+ signOut: "/auth/signout",
30
+ },
31
+ session: {
32
+ maxAge: 60 * 14,
33
+ },
34
+ callbacks,
35
+ trustHost: true,
36
+ });
37
+
38
+ export const auth = isAuthDisabled ? async () => mockSession : nextAuthResult!.auth;
39
+
40
+ export const GET = isAuthDisabled
41
+ ? async (request: Request) => {
42
+ const pathname = new URL(request.url).pathname;
43
+
44
+ if (pathname.endsWith("/session")) {
45
+ return NextResponse.json(mockSession);
46
+ }
47
+
48
+ return new NextResponse(null, { status: 204 });
49
+ }
50
+ : nextAuthResult!.handlers.GET;
51
+
52
+ export const POST = isAuthDisabled
53
+ ? async () => new NextResponse(null, { status: 204 })
54
+ : nextAuthResult!.handlers.POST;
55
+
56
+ export const signOut = isAuthDisabled ? async () => undefined : nextAuthResult!.signOut;
57
+ export const signIn = isAuthDisabled ? async () => undefined : nextAuthResult!.signIn;
58
+
59
+ export { isAuthDisabled, mockSession };
@@ -0,0 +1,226 @@
1
+ import type { NextRequest } from "next/server.js";
2
+ import type { Account, Session } from "next-auth";
3
+ import type { JWT } from "next-auth/jwt";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { callbacks } from "./callbacks";
6
+
7
+ describe("callbacks", () => {
8
+ const mockJWT: JWT = {
9
+ access_token: "old_access_token",
10
+ refresh_token: "refresh_token",
11
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
12
+ };
13
+
14
+ const mockAccount: Account = {
15
+ provider: "pingid",
16
+ type: "oidc",
17
+ providerAccountId: "user123",
18
+ access_token: "new_access_token",
19
+ refresh_token: "new_refresh_token",
20
+ expires_at: Math.floor(Date.now() / 1000) + 7200,
21
+ };
22
+
23
+ const mockSession: Session = {
24
+ user: { name: "John Doe", email: "john@example.com" },
25
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
26
+ };
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ vi.stubGlobal("fetch", vi.fn());
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ describe("jwt callback", () => {
38
+ it("should map account fields to token on initial sign-in", async () => {
39
+ const result = await callbacks.jwt({ token: mockJWT, account: mockAccount });
40
+
41
+ expect(result.access_token).toBe("new_access_token");
42
+ expect(result.refresh_token).toBe("new_refresh_token");
43
+ expect(result.expires_at).toBe(mockAccount.expires_at);
44
+ });
45
+
46
+ it("should return token unchanged when no account and token not expired", async () => {
47
+ const futureExpiry = Math.floor(Date.now() / 1000) + 3600;
48
+ const token = { ...mockJWT, expires_at: futureExpiry };
49
+
50
+ const result = await callbacks.jwt({ token });
51
+
52
+ expect(result).toEqual(token);
53
+ });
54
+
55
+ it("should attempt token refresh when token is expired and no account", async () => {
56
+ const pastExpiry = Math.floor(Date.now() / 1000) - 1000;
57
+ const token = { ...mockJWT, expires_at: pastExpiry };
58
+
59
+ const mockFetch = vi.fn().mockResolvedValue({
60
+ ok: true,
61
+ json: async () => ({
62
+ access_token: "refreshed_access_token",
63
+ refresh_token: "refreshed_refresh_token",
64
+ expires_in: 3600,
65
+ }),
66
+ });
67
+
68
+ vi.stubGlobal("fetch", mockFetch);
69
+ vi.stubEnv("SSO_TOKEN_ENDPOINT_URL", "https://sso.example.com/token");
70
+ vi.stubEnv("CLIENT_ID", "client123");
71
+
72
+ const result = await callbacks.jwt({ token });
73
+
74
+ expect(result.access_token).toBe("refreshed_access_token");
75
+ expect(result.refresh_token).toBe("refreshed_refresh_token");
76
+ expect(mockFetch).toHaveBeenCalledWith(
77
+ "https://sso.example.com/token",
78
+ expect.objectContaining({
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
81
+ }),
82
+ );
83
+ });
84
+
85
+ it("should return error object when token refresh fails", async () => {
86
+ const pastExpiry = Math.floor(Date.now() / 1000) - 1000;
87
+ const token = { ...mockJWT, expires_at: pastExpiry };
88
+
89
+ const mockFetch = vi.fn().mockResolvedValue({
90
+ ok: false,
91
+ statusText: "Unauthorized",
92
+ });
93
+
94
+ vi.stubGlobal("fetch", mockFetch);
95
+ vi.stubEnv("SSO_TOKEN_ENDPOINT_URL", "https://sso.example.com/token");
96
+ vi.stubEnv("CLIENT_ID", "client123");
97
+
98
+ const result = await callbacks.jwt({ token });
99
+
100
+ expect(result).toEqual({ error: "RefreshAccessTokenError" });
101
+ });
102
+
103
+ it("should handle fetch exception during token refresh", async () => {
104
+ const pastExpiry = Math.floor(Date.now() / 1000) - 1000;
105
+ const token = { ...mockJWT, expires_at: pastExpiry };
106
+
107
+ const mockFetch = vi.fn().mockRejectedValue(new Error("Network error"));
108
+
109
+ vi.stubGlobal("fetch", mockFetch);
110
+ vi.stubEnv("SSO_TOKEN_ENDPOINT_URL", "https://sso.example.com/token");
111
+ vi.stubEnv("CLIENT_ID", "client123");
112
+
113
+ const result = await callbacks.jwt({ token });
114
+
115
+ expect(result).toEqual({ error: "RefreshAccessTokenError" });
116
+ });
117
+
118
+ it("should use zero for expires_in when not provided in response", async () => {
119
+ const pastExpiry = Math.floor(Date.now() / 1000) - 1000;
120
+ const token = { ...mockJWT, expires_at: pastExpiry };
121
+
122
+ const mockFetch = vi.fn().mockResolvedValue({
123
+ ok: true,
124
+ json: async () => ({
125
+ access_token: "refreshed_access_token",
126
+ refresh_token: "refreshed_refresh_token",
127
+ }),
128
+ });
129
+
130
+ vi.stubGlobal("fetch", mockFetch);
131
+ vi.stubEnv("SSO_TOKEN_ENDPOINT_URL", "https://sso.example.com/token");
132
+ vi.stubEnv("CLIENT_ID", "client123");
133
+
134
+ const timeBefore = Math.floor(Date.now() / 1000);
135
+ const result = await callbacks.jwt({ token });
136
+ const timeAfter = Math.floor(Date.now() / 1000);
137
+
138
+ expect(result.access_token).toBe("refreshed_access_token");
139
+ expect(result.expires_at).toBeGreaterThanOrEqual(timeBefore);
140
+ expect(result.expires_at).toBeLessThanOrEqual(timeAfter + 1);
141
+ });
142
+
143
+ it("should use URLSearchParams to build request body correctly", async () => {
144
+ const pastExpiry = Math.floor(Date.now() / 1000) - 1000;
145
+ const token = { ...mockJWT, expires_at: pastExpiry, refresh_token: "my-refresh-token" };
146
+
147
+ const mockFetch = vi.fn().mockResolvedValue({
148
+ ok: true,
149
+ json: async () => ({
150
+ access_token: "new_token",
151
+ refresh_token: "new_refresh_token",
152
+ expires_in: 3600,
153
+ }),
154
+ });
155
+
156
+ vi.stubGlobal("fetch", mockFetch);
157
+ vi.stubEnv("SSO_TOKEN_ENDPOINT_URL", "https://sso.example.com/token");
158
+ vi.stubEnv("CLIENT_ID", "client123");
159
+
160
+ await callbacks.jwt({ token });
161
+
162
+ const callArgs = mockFetch.mock.calls[0];
163
+ const body = callArgs[1].body as URLSearchParams;
164
+
165
+ expect(body.get("grant_type")).toBe("refresh_token");
166
+ expect(body.get("client_id")).toBe("client123");
167
+ expect(body.get("refresh_token")).toBe("my-refresh-token");
168
+ });
169
+ });
170
+
171
+ describe("session callback", () => {
172
+ it("should map token fields to session", async () => {
173
+ const token = {
174
+ access_token: "session_access_token",
175
+ expires_at: 1234567890,
176
+ };
177
+
178
+ const result = await callbacks.session({ session: mockSession, token });
179
+
180
+ expect(result.expires_at).toBe(1234567890);
181
+ expect(result.access_token).toBe("session_access_token");
182
+ expect(result.user).toBe(mockSession.user);
183
+ });
184
+
185
+ it("should preserve existing session user data", async () => {
186
+ const session: Session = {
187
+ ...mockSession,
188
+ user: { name: "Jane", email: "jane@example.com", image: "avatar.jpg" },
189
+ };
190
+ const token = { access_token: "token123", expires_at: 9999999999 };
191
+
192
+ const result = await callbacks.session({ session, token });
193
+
194
+ expect(result.user).toEqual(session.user);
195
+ expect(result.access_token).toBe("token123");
196
+ });
197
+ });
198
+
199
+ describe("authorized callback", () => {
200
+ it("should return true when session has access_token", async () => {
201
+ const request = {} as NextRequest;
202
+ const auth = { user: { email: "user@example.com" }, access_token: "token123" } as any;
203
+
204
+ const result = await callbacks.authorized({ request, auth });
205
+
206
+ expect(result).toBe(true);
207
+ });
208
+
209
+ it("should return false when session is null", async () => {
210
+ const request = {} as NextRequest;
211
+
212
+ const result = await callbacks.authorized({ request, auth: null });
213
+
214
+ expect(result).toBe(false);
215
+ });
216
+
217
+ it("should return false when session has no access_token", async () => {
218
+ const request = {} as NextRequest;
219
+ const auth = { user: { email: "user@example.com" } } as any;
220
+
221
+ const result = await callbacks.authorized({ request, auth });
222
+
223
+ expect(result).toBe(false);
224
+ });
225
+ });
226
+ });
@@ -0,0 +1,68 @@
1
+ import type { NextRequest } from "next/server.js";
2
+ import type { Account, Session } from "next-auth";
3
+ import type { JWT } from "next-auth/jwt";
4
+
5
+ async function jwt({ token, account }: { token: JWT; account?: Account | null }) {
6
+ // The first time jwt is called, account exists. We map fields from account to token
7
+ // which is passed to the session callback. User is also available in this callback which
8
+ // comes from the profile function in the provider. We can use this in the future to surface
9
+ // more user details in session
10
+
11
+ if (account) {
12
+ token.expires_at = account.expires_at;
13
+ token.access_token = account.access_token;
14
+ token.refresh_token = account.refresh_token;
15
+ } else if (token.expires_at && Date.now() < token.expires_at * 1000) {
16
+ return token;
17
+ } else {
18
+ try {
19
+ const response = await fetch(`${process.env.SSO_TOKEN_ENDPOINT_URL}`, {
20
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
21
+ body: new URLSearchParams({
22
+ client_id: process.env.CLIENT_ID || "",
23
+ grant_type: "refresh_token",
24
+ refresh_token: token.refresh_token || "",
25
+ }),
26
+ method: "POST",
27
+ });
28
+
29
+ const tokens = await response.json();
30
+
31
+ if (!response.ok) throw tokens;
32
+
33
+ const newToken = {
34
+ ...token,
35
+ access_token: tokens.access_token,
36
+ expires_at: Math.floor(Date.now() / 1000 + (tokens.expires_in ? tokens.expires_in : 0)),
37
+ refresh_token: tokens.refresh_token ?? token.refresh_token,
38
+ };
39
+
40
+ return newToken;
41
+ } catch (_error) {
42
+ return { error: "RefreshAccessTokenError" as const };
43
+ }
44
+ }
45
+
46
+ return token;
47
+ }
48
+
49
+ async function session({ session, token }: { session: Session; token: JWT }) {
50
+ // We map fields from token to make them available in the session.
51
+ session.expires_at = token.expires_at;
52
+ session.access_token = token.access_token;
53
+
54
+ return session;
55
+ }
56
+
57
+ async function authorized({ auth }: { request: NextRequest; auth: Session | null }) {
58
+ // If there is no access token, the user is not authorized. Returning false from this
59
+ // callback will redirect the user to the sign in page.
60
+
61
+ return !!auth?.access_token;
62
+ }
63
+
64
+ export const callbacks = {
65
+ jwt,
66
+ session,
67
+ authorized,
68
+ };
@@ -0,0 +1,116 @@
1
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
2
+ import { useSession } from "next-auth/react";
3
+ import { useEffect } from "react";
4
+ import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
5
+ import { ClientAuthGuard } from "./client-auth-guard";
6
+
7
+ // Mock Next.js navigation hooks
8
+ vi.mock("next/navigation", () => ({
9
+ usePathname: vi.fn(),
10
+ useRouter: vi.fn(),
11
+ useSearchParams: vi.fn(),
12
+ }));
13
+
14
+ // Mock next-auth
15
+ vi.mock("next-auth/react", () => ({
16
+ useSession: vi.fn(),
17
+ }));
18
+
19
+ // Mock React hooks
20
+ vi.mock("react", async () => {
21
+ const actual = await vi.importActual("react");
22
+ return {
23
+ ...actual,
24
+ useEffect: vi.fn((fn) => fn()),
25
+ };
26
+ });
27
+
28
+ describe("AuthGuard", () => {
29
+ const mockPush = vi.fn();
30
+ const mockSearchParams = {
31
+ toString: vi.fn(),
32
+ };
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ (useRouter as Mock).mockReturnValue({ push: mockPush });
37
+ (useSearchParams as Mock).mockReturnValue(mockSearchParams);
38
+ });
39
+
40
+ it("should render children when authenticated", () => {
41
+ (useSession as Mock).mockReturnValue({ status: "authenticated" });
42
+ (usePathname as Mock).mockReturnValue("/dashboard");
43
+ mockSearchParams.toString.mockReturnValue("");
44
+
45
+ const children = "Protected Content";
46
+ const result = ClientAuthGuard({ children });
47
+
48
+ expect(result).toBe(children);
49
+ expect(mockPush).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("should return null when loading", () => {
53
+ (useSession as Mock).mockReturnValue({ status: "loading" });
54
+ (usePathname as Mock).mockReturnValue("/dashboard");
55
+ mockSearchParams.toString.mockReturnValue("");
56
+
57
+ const result = ClientAuthGuard({ children: "Protected Content" });
58
+
59
+ expect(result).toBeNull();
60
+ expect(mockPush).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it("should redirect to signin when unauthenticated", () => {
64
+ (useSession as Mock).mockReturnValue({ status: "unauthenticated" });
65
+ (usePathname as Mock).mockReturnValue("/dashboard");
66
+ mockSearchParams.toString.mockReturnValue("");
67
+
68
+ ClientAuthGuard({ children: "Protected Content" });
69
+
70
+ expect(mockPush).toHaveBeenCalledWith("/auth/signin?callbackUrl=%2Fdashboard");
71
+ });
72
+
73
+ it("should redirect to root when already on signin page", () => {
74
+ (useSession as Mock).mockReturnValue({ status: "unauthenticated" });
75
+ (usePathname as Mock).mockReturnValue("/auth/signin");
76
+ mockSearchParams.toString.mockReturnValue("");
77
+
78
+ ClientAuthGuard({ children: "Protected Content" });
79
+
80
+ expect(mockPush).toHaveBeenCalledWith("/auth/signin?callbackUrl=%2F");
81
+ });
82
+
83
+ it("should include search params in callback URL", () => {
84
+ (useSession as Mock).mockReturnValue({ status: "unauthenticated" });
85
+ (usePathname as Mock).mockReturnValue("/dashboard");
86
+ mockSearchParams.toString.mockReturnValue("tab=settings&view=list");
87
+
88
+ ClientAuthGuard({ children: "Protected Content" });
89
+
90
+ expect(mockPush).toHaveBeenCalledWith(
91
+ "/auth/signin?callbackUrl=%2Fdashboard%3Ftab%3Dsettings%26view%3Dlist",
92
+ );
93
+ });
94
+
95
+ it("should properly encode special characters in callback URL", () => {
96
+ (useSession as Mock).mockReturnValue({ status: "unauthenticated" });
97
+ (usePathname as Mock).mockReturnValue("/dashboard/item");
98
+ mockSearchParams.toString.mockReturnValue("name=John Doe&email=test@example.com");
99
+
100
+ ClientAuthGuard({ children: "Protected Content" });
101
+
102
+ expect(mockPush).toHaveBeenCalledWith(
103
+ "/auth/signin?callbackUrl=%2Fdashboard%2Fitem%3Fname%3DJohn%20Doe%26email%3Dtest%40example.com",
104
+ );
105
+ });
106
+
107
+ it("should call useEffect with correct dependencies", () => {
108
+ (useSession as Mock).mockReturnValue({ status: "authenticated" });
109
+ (usePathname as Mock).mockReturnValue("/dashboard");
110
+ mockSearchParams.toString.mockReturnValue("");
111
+
112
+ ClientAuthGuard({ children: "Protected Content" });
113
+
114
+ expect(useEffect).toHaveBeenCalled();
115
+ });
116
+ });