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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# ms-graph setup guide
|
|
2
|
+
|
|
3
|
+
## 1. Add environment variables
|
|
4
|
+
|
|
5
|
+
Add the following variables to your `.env` file:
|
|
6
|
+
|
|
7
|
+
```env
|
|
8
|
+
MS_GRAPH_CLIENT_ID=
|
|
9
|
+
MS_GRAPH_CLIENT_SECRET=
|
|
10
|
+
MS_GRAPH_TENANT_ID=
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 2. Create the API route
|
|
14
|
+
|
|
15
|
+
In your Next.js app, create the following file `/api/[email]/photo/route.ts` with this content:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
export { GET_USER_PHOTO as GET } from 'acinguiux-dnr-utils/ms-graph';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 3. Use the photo in your app
|
|
22
|
+
|
|
23
|
+
You can now use `/api/${email}/photo` as the image source.
|
|
24
|
+
|
|
25
|
+
### 3.1. SDS Avatar component
|
|
26
|
+
|
|
27
|
+
```diff
|
|
28
|
+
<Avatar
|
|
29
|
+
label="John Doe"
|
|
30
|
+
abbreviation="JD"
|
|
31
|
+
+ src="/api/john.doe@shell.com/photo"
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the user has a photo in Azure AD, it will display the image. If no photo exists, it will fall back to showing the abbreviation (e.g. “JDâ€).
|
|
36
|
+
|
|
37
|
+
### 3.2. Layout component
|
|
38
|
+
|
|
39
|
+
To display the currently logged-in user's photo in the `NavBar` when using the `Layout` component from `geneva-component`, simply add the `userPhotoSrc` prop:
|
|
40
|
+
|
|
41
|
+
```diff
|
|
42
|
+
<Layout
|
|
43
|
+
items={navItems}
|
|
44
|
+
user={session?.user}
|
|
45
|
+
+ userPhotoSrc={`/api/${session?.user?.email}/photo`}
|
|
46
|
+
>
|
|
47
|
+
```
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getGraphToken } from "./get-graph-token";
|
|
4
|
+
|
|
5
|
+
// Mock fetch
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
global.fetch = mockFetch;
|
|
8
|
+
|
|
9
|
+
// Store original environment variables
|
|
10
|
+
const originalEnv = { ...process.env };
|
|
11
|
+
|
|
12
|
+
describe("getGraphToken", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
// Setup environment variables
|
|
15
|
+
process.env.MS_GRAPH_CLIENT_ID = "test-client-id";
|
|
16
|
+
process.env.MS_GRAPH_CLIENT_SECRET = "test-client-secret";
|
|
17
|
+
process.env.MS_GRAPH_TENANT_ID = "test-tenant-id";
|
|
18
|
+
|
|
19
|
+
// Clear mocks between tests
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
// Restore original environment variables
|
|
25
|
+
process.env = { ...originalEnv };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should fetch a new token if cache is empty", async () => {
|
|
29
|
+
mockFetch.mockResolvedValueOnce({
|
|
30
|
+
ok: true,
|
|
31
|
+
json: () =>
|
|
32
|
+
Promise.resolve({
|
|
33
|
+
access_token: "new-access-token",
|
|
34
|
+
expires_in: 3600,
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const token = await getGraphToken();
|
|
39
|
+
|
|
40
|
+
expect(token).toBe("new-access-token");
|
|
41
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
43
|
+
`https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token`,
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
47
|
+
body: expect.any(String),
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should throw an error if fetch fails", async () => {
|
|
53
|
+
mockFetch.mockResolvedValueOnce({ ok: false });
|
|
54
|
+
|
|
55
|
+
await expect(getGraphToken()).rejects.toThrow("Unable to obtain Graph token");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should throw error when MS_GRAPH_TENANT_ID is not set", async () => {
|
|
59
|
+
delete process.env.MS_GRAPH_TENANT_ID;
|
|
60
|
+
|
|
61
|
+
await expect(getGraphToken()).rejects.toThrow(
|
|
62
|
+
"MS_GRAPH_TENANT_ID environment variable is not set",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should throw error when MS_GRAPH_CLIENT_ID is not set", async () => {
|
|
67
|
+
delete process.env.MS_GRAPH_CLIENT_ID;
|
|
68
|
+
|
|
69
|
+
await expect(getGraphToken()).rejects.toThrow(
|
|
70
|
+
"MS_GRAPH_CLIENT_ID environment variable is not set",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should throw error when MS_GRAPH_CLIENT_SECRET is not set", async () => {
|
|
75
|
+
delete process.env.MS_GRAPH_CLIENT_SECRET;
|
|
76
|
+
|
|
77
|
+
await expect(getGraphToken()).rejects.toThrow(
|
|
78
|
+
"MS_GRAPH_CLIENT_SECRET environment variable is not set",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should use correct body parameters in fetch request", async () => {
|
|
83
|
+
mockFetch.mockResolvedValueOnce({
|
|
84
|
+
ok: true,
|
|
85
|
+
json: () => Promise.resolve({ access_token: "token" }),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await getGraphToken();
|
|
89
|
+
|
|
90
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
91
|
+
const body = callArgs[1].body as string;
|
|
92
|
+
const params = new URLSearchParams(body);
|
|
93
|
+
|
|
94
|
+
expect(params.get("grant_type")).toBe("client_credentials");
|
|
95
|
+
expect(params.get("client_id")).toBe("test-client-id");
|
|
96
|
+
expect(params.get("client_secret")).toBe("test-client-secret");
|
|
97
|
+
expect(params.get("scope")).toBe("https://graph.microsoft.com/.default");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should set cache and next revalidate options", async () => {
|
|
101
|
+
mockFetch.mockResolvedValueOnce({
|
|
102
|
+
ok: true,
|
|
103
|
+
json: () => Promise.resolve({ access_token: "token" }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await getGraphToken();
|
|
107
|
+
|
|
108
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
109
|
+
expect.any(String),
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
cache: "force-cache",
|
|
112
|
+
next: { revalidate: 3540 }, // 3600 - 60 seconds
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const TOKEN_EXPIRATION_TIME = 3600 - 60; // 1 hour minus 1 minute to make sure we always have a valid token
|
|
2
|
+
|
|
3
|
+
export async function getGraphToken(): Promise<string> {
|
|
4
|
+
if (!process.env.MS_GRAPH_TENANT_ID) {
|
|
5
|
+
throw new Error("MS_GRAPH_TENANT_ID environment variable is not set");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (!process.env.MS_GRAPH_CLIENT_ID) {
|
|
9
|
+
throw new Error("MS_GRAPH_CLIENT_ID environment variable is not set");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!process.env.MS_GRAPH_CLIENT_SECRET) {
|
|
13
|
+
throw new Error("MS_GRAPH_CLIENT_SECRET environment variable is not set");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const params = new URLSearchParams();
|
|
17
|
+
params.append("grant_type", "client_credentials");
|
|
18
|
+
params.append("client_id", process.env.MS_GRAPH_CLIENT_ID);
|
|
19
|
+
params.append("client_secret", process.env.MS_GRAPH_CLIENT_SECRET);
|
|
20
|
+
params.append("scope", "https://graph.microsoft.com/.default");
|
|
21
|
+
const body = params.toString();
|
|
22
|
+
|
|
23
|
+
const res = await fetch(
|
|
24
|
+
`https://login.microsoftonline.com/${process.env.MS_GRAPH_TENANT_ID}/oauth2/v2.0/token`,
|
|
25
|
+
{
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
28
|
+
body,
|
|
29
|
+
cache: "force-cache",
|
|
30
|
+
next: { revalidate: TOKEN_EXPIRATION_TIME },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
throw new Error("Unable to obtain Graph token");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
|
|
40
|
+
return json.access_token;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GET as GET_USER_PHOTO } from "./user-photo";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getGraphToken } from "./get-graph-token";
|
|
3
|
+
import { GET } from "./user-photo";
|
|
4
|
+
|
|
5
|
+
// Mock dependencies
|
|
6
|
+
vi.mock("./get-graph-token", () => ({
|
|
7
|
+
getGraphToken: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("Avatar GET function", () => {
|
|
11
|
+
const mockEmail = "test@example.com";
|
|
12
|
+
const mockToken = "mock-token";
|
|
13
|
+
const mockRequest = {} as Request;
|
|
14
|
+
const mockParams = { params: { email: mockEmail } };
|
|
15
|
+
|
|
16
|
+
let originalFetch: typeof global.fetch;
|
|
17
|
+
let mockFetch: any;
|
|
18
|
+
let consoleErrorSpy: any;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// Save original fetch and console.error
|
|
22
|
+
originalFetch = global.fetch;
|
|
23
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
24
|
+
|
|
25
|
+
// Mock fetch
|
|
26
|
+
mockFetch = vi.fn();
|
|
27
|
+
global.fetch = mockFetch;
|
|
28
|
+
|
|
29
|
+
// Setup getGraphToken mock
|
|
30
|
+
vi.mocked(getGraphToken).mockResolvedValue(mockToken);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
// Restore original functions
|
|
35
|
+
global.fetch = originalFetch;
|
|
36
|
+
consoleErrorSpy.mockRestore();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return photo image when graph API returns successful response", async () => {
|
|
41
|
+
// Mock successful response with body
|
|
42
|
+
const mockBody = new ReadableStream();
|
|
43
|
+
mockFetch.mockResolvedValueOnce({
|
|
44
|
+
ok: true,
|
|
45
|
+
body: mockBody,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const response = await GET(mockRequest, mockParams);
|
|
49
|
+
|
|
50
|
+
// Verify fetch was called correctly
|
|
51
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
52
|
+
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mockEmail)}/photo/$value`,
|
|
53
|
+
{ headers: { Authorization: `Bearer ${mockToken}` } },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Verify response
|
|
57
|
+
expect(response).toBeInstanceOf(Response);
|
|
58
|
+
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
|
59
|
+
expect(response.headers.get("Cache-Control")).toBe(
|
|
60
|
+
"public, max-age=86400, stale-while-revalidate=43200",
|
|
61
|
+
);
|
|
62
|
+
expect(await response.body).toBe(mockBody);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should return 204 No Content when graph API returns 404", async () => {
|
|
66
|
+
// Mock 404 response
|
|
67
|
+
mockFetch.mockResolvedValueOnce({
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 404,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const response = await GET(mockRequest, mockParams);
|
|
73
|
+
|
|
74
|
+
expect(response).toBeInstanceOf(Response);
|
|
75
|
+
expect(response.status).toBe(204);
|
|
76
|
+
expect(await response.text()).toBe("");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should return error response with original status when graph API returns error", async () => {
|
|
80
|
+
// Mock error response
|
|
81
|
+
const errorStatus = 403;
|
|
82
|
+
const errorBody = "Access denied";
|
|
83
|
+
mockFetch.mockResolvedValueOnce({
|
|
84
|
+
ok: false,
|
|
85
|
+
status: errorStatus,
|
|
86
|
+
text: () => Promise.resolve(errorBody),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const response = await GET(mockRequest, mockParams);
|
|
90
|
+
|
|
91
|
+
expect(response).toBeInstanceOf(Response);
|
|
92
|
+
expect(response.status).toBe(errorStatus);
|
|
93
|
+
expect(await response.text()).toBe(errorBody);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should handle exceptions and return 500 error", async () => {
|
|
97
|
+
// Mock fetch to throw an error
|
|
98
|
+
const mockError = new Error("Network error");
|
|
99
|
+
mockFetch.mockRejectedValueOnce(mockError);
|
|
100
|
+
|
|
101
|
+
const response = await GET(mockRequest, mockParams);
|
|
102
|
+
|
|
103
|
+
expect(response).toBeInstanceOf(Response);
|
|
104
|
+
expect(response.status).toBe(500);
|
|
105
|
+
expect(await response.text()).toBe("Internal error fetching photo");
|
|
106
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Photo fetch error:", mockError);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getGraphToken } from "./get-graph-token";
|
|
2
|
+
|
|
3
|
+
export async function GET(_request: Request, { params }: { params: { email: string } }) {
|
|
4
|
+
const email = params.email;
|
|
5
|
+
|
|
6
|
+
if (
|
|
7
|
+
!process.env.MS_GRAPH_TENANT_ID ||
|
|
8
|
+
!process.env.MS_GRAPH_CLIENT_ID ||
|
|
9
|
+
!process.env.MS_GRAPH_CLIENT_SECRET
|
|
10
|
+
) {
|
|
11
|
+
return new Response(null, { status: 204 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const token = await getGraphToken();
|
|
16
|
+
const graphRes = await fetch(
|
|
17
|
+
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(email)}/photo/$value`,
|
|
18
|
+
{
|
|
19
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (graphRes.ok) {
|
|
24
|
+
return new Response(graphRes.body, {
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "image/jpeg",
|
|
27
|
+
// browser caching (24 h, stale-while-revalidate 12 h)
|
|
28
|
+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=43200",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (graphRes.status === 404) {
|
|
34
|
+
return new Response(null, { status: 204 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const body = await graphRes.text();
|
|
38
|
+
|
|
39
|
+
return new Response(body, { status: graphRes.status });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error("Photo fetch error:", err);
|
|
42
|
+
|
|
43
|
+
return new Response("Internal error fetching photo", { status: 500 });
|
|
44
|
+
}
|
|
45
|
+
}
|
package/tsconfig.json
ADDED
package/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import "next-auth";
|
|
2
|
+
import "next-auth/jwt";
|
|
3
|
+
import { UserAuthorization } from "./provider";
|
|
4
|
+
|
|
5
|
+
declare module "next-auth" {
|
|
6
|
+
interface User {
|
|
7
|
+
name?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
roles?: UserAuthorization[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Session {
|
|
13
|
+
expires_at?: number;
|
|
14
|
+
access_token?: string;
|
|
15
|
+
refresh_token?: string;
|
|
16
|
+
user?: User;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module "next-auth/jwt" {
|
|
21
|
+
interface JWT {
|
|
22
|
+
expires_at?: number;
|
|
23
|
+
access_token?: string;
|
|
24
|
+
refresh_token?: string;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { default as sharedConfig } from "acinguiux-dnr-vitest/config";
|
|
2
|
+
import { defineConfig, mergeConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
const config = mergeConfig(
|
|
5
|
+
sharedConfig,
|
|
6
|
+
defineConfig({
|
|
7
|
+
test: {
|
|
8
|
+
coverage: {
|
|
9
|
+
thresholds: {
|
|
10
|
+
statements: 32,
|
|
11
|
+
branches: 91,
|
|
12
|
+
functions: 76,
|
|
13
|
+
lines: 32,
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export default config;
|