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.
- package/CHANGELOG.md +55 -0
- package/README.md +43 -0
- package/package.json +57 -0
- package/src/application-insights/index.ts +37 -0
- package/src/authentication/auth.ts +59 -0
- package/src/authentication/callbacks.test.ts +226 -0
- package/src/authentication/callbacks.ts +68 -0
- package/src/authentication/client-auth-guard.test.ts +116 -0
- package/src/authentication/client-auth-guard.ts +29 -0
- package/src/authentication/helpers.test.ts +41 -0
- package/src/authentication/helpers.ts +14 -0
- package/src/authentication/index.ts +8 -0
- package/src/authentication/middleware.ts +17 -0
- package/src/authentication/provider.test.ts +168 -0
- package/src/authentication/provider.ts +56 -0
- package/src/authentication/services/permissions.test.ts +102 -0
- package/src/authentication/services/permissions.ts +52 -0
- package/src/formatters/index.ts +1 -0
- package/src/formatters/number.test.ts +49 -0
- package/src/formatters/number.ts +25 -0
- package/src/instrumentation/browser.test.ts +118 -0
- package/src/instrumentation/browser.ts +24 -0
- package/src/instrumentation/node.test.ts +63 -0
- package/src/instrumentation/node.ts +81 -0
- package/src/loggers/auth-fetch.test.ts +383 -0
- package/src/loggers/auth-fetch.ts +79 -0
- package/src/loggers/index.ts +1 -0
- package/src/mappers/index.ts +1 -0
- package/src/mappers/name.test.ts +45 -0
- package/src/mappers/name.ts +16 -0
- package/src/ms-graph/README.md +47 -0
- package/src/ms-graph/get-graph-token.test.ts +116 -0
- package/src/ms-graph/get-graph-token.ts +41 -0
- package/src/ms-graph/index.ts +1 -0
- package/src/ms-graph/user-photo.test.ts +108 -0
- package/src/ms-graph/user-photo.ts +45 -0
- package/tsconfig.json +12 -0
- package/types.d.ts +26 -0
- 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
|
+
});
|