@unidir/unidir-nextjs 1.0.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/README.md +151 -0
- package/package.json +38 -0
- package/src/client.tsx +101 -0
- package/src/index.tsx +198 -0
- package/src/jwks.ts +19 -0
- package/src/pkce.ts +18 -0
- package/src/session.ts +21 -0
- package/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @unidir/unidir-nextjs
|
|
2
|
+
|
|
3
|
+
The official UniDir SDK for Next.js applications. This SDK provides secure, server-side OpenID Connect (OIDC) authentication using encrypted `httpOnly` cookies.
|
|
4
|
+
|
|
5
|
+
## 🚀 Key Features
|
|
6
|
+
|
|
7
|
+
- **Zero-Flicker Auth:** Validate sessions in Server Components before the page is sent to the browser.
|
|
8
|
+
- **Encrypted Sessions:** Uses JWE (JSON Web Encryption) to ensure session data is unreadable and tamper-proof.
|
|
9
|
+
- **App Router Optimized:** Built for Next.js 13, 14, and 15, including full support for Middleware and Server Actions.
|
|
10
|
+
- **Secure by Default:** Tokens are exchanged on the server-side, keeping your `clientSecret` hidden from the browser.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## ⚙️ Installation
|
|
15
|
+
|
|
16
|
+
Install the SDK and its core peer dependencies:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @unidir/unidir-nextjs jose cookie
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 🛠️ Setup Guide
|
|
24
|
+
|
|
25
|
+
### 1. Environment Variables
|
|
26
|
+
|
|
27
|
+
Create a `.env.local` file in your project root.
|
|
28
|
+
|
|
29
|
+
> **Note:** `UNIDIR_SECRET` must be a random string of at least 32 characters.
|
|
30
|
+
|
|
31
|
+
```env
|
|
32
|
+
UNIDIR_DOMAIN=[https://your-tenant.unidir.io](https://your-tenant.unidir.io)
|
|
33
|
+
UNIDIR_CLIENT_ID=your_client_id
|
|
34
|
+
UNIDIR_CLIENT_SECRET=your_client_secret
|
|
35
|
+
UNIDIR_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
|
36
|
+
UNIDIR_SECRET='your_32_character_session_secret'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Initialize the SDK
|
|
40
|
+
|
|
41
|
+
Create a shared library file to export your UniDir instance. This singleton will be used across your application.
|
|
42
|
+
|
|
43
|
+
**lib/unidir.ts**
|
|
44
|
+
|
|
45
|
+
````typescript
|
|
46
|
+
import { initUniDir } from '@unidir/unidir-nextjs';
|
|
47
|
+
|
|
48
|
+
export const unidir = initUniDir({
|
|
49
|
+
domain: process.env.UNIDIR_DOMAIN!,
|
|
50
|
+
clientId: process.env.UNIDIR_CLIENT_ID!,
|
|
51
|
+
clientSecret: process.env.UNIDIR_CLIENT_SECRET!,
|
|
52
|
+
secret: process.env.UNIDIR_SECRET!,
|
|
53
|
+
redirectUri: process.env.UNIDIR_REDIRECT_URI!,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
### 3. API Route Handler
|
|
57
|
+
Create a catch-all route to handle the authentication flow (login, logout, and callback).
|
|
58
|
+
|
|
59
|
+
**app/api/auth/[unidir]/route.ts**
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { unidir } from '@/lib/unidir';
|
|
63
|
+
|
|
64
|
+
export const GET = unidir.handleAuth();
|
|
65
|
+
````
|
|
66
|
+
|
|
67
|
+
## 📖 Usage Gallery
|
|
68
|
+
|
|
69
|
+
### Protecting Server Components (HOC)
|
|
70
|
+
|
|
71
|
+
Use `withPageAuthRequired` to wrap pages that require authentication. It handles the redirect logic automatically.
|
|
72
|
+
|
|
73
|
+
**app/dashboard/page.tsx**
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { unidir } from "@/lib/unidir";
|
|
77
|
+
|
|
78
|
+
async function Dashboard({ user }: { user: any }) {
|
|
79
|
+
return (
|
|
80
|
+
<div>
|
|
81
|
+
<h1>Protected Dashboard</h1>
|
|
82
|
+
<p>Welcome back, {user.name}!</p>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default unidir.withPageAuthRequired(Dashboard);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Accessing Auth State on the Client
|
|
91
|
+
|
|
92
|
+
Wrap your root layout with the `UserProvider` and use the `useUser` hook in client components.
|
|
93
|
+
|
|
94
|
+
**app/layout.tsx**
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { UserProvider } from "@unidir/unidir-nextjs";
|
|
98
|
+
|
|
99
|
+
export default function RootLayout({ children }) {
|
|
100
|
+
return (
|
|
101
|
+
<html lang="en">
|
|
102
|
+
<body>
|
|
103
|
+
<UserProvider>{children}</UserProvider>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**components/UserMenu.tsx**
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
"use client";
|
|
114
|
+
|
|
115
|
+
import { useUser } from "@unidir/unidir-nextjs";
|
|
116
|
+
|
|
117
|
+
export default function UserMenu() {
|
|
118
|
+
const { user, isLoading } = useUser();
|
|
119
|
+
|
|
120
|
+
if (isLoading) return <span>Loading...</span>;
|
|
121
|
+
|
|
122
|
+
return user ? (
|
|
123
|
+
<a href="/api/auth/logout">Logout</a>
|
|
124
|
+
) : (
|
|
125
|
+
<a href="/api/auth/login">Login</a>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
🔒 Security Architecture
|
|
131
|
+
httpOnly Cookies: Session tokens are stored in cookies that cannot be accessed via JavaScript (document.cookie), which prevents XSS-based token theft.
|
|
132
|
+
|
|
133
|
+
Server-Side Exchange: The client_secret is used only on the server to exchange authorization codes for tokens.
|
|
134
|
+
|
|
135
|
+
Tamper-Proof: Every session is signed and encrypted. If the UNIDIR_SECRET is compromised or the cookie is modified, the session becomes invalid.
|
|
136
|
+
|
|
137
|
+
## 📄 API Reference
|
|
138
|
+
|
|
139
|
+
| Method | Environment | Description |
|
|
140
|
+
| :--------------------------- | :--------------- | :---------------------------------------------------- |
|
|
141
|
+
| `handleAuth()` | Server (API) | Main router for `/login`, `/logout`, and `/callback`. |
|
|
142
|
+
| `getSession(req)` | Server (SSR/API) | Returns the decrypted user session. |
|
|
143
|
+
| `withPageAuthRequired(Comp)` | Server (Page) | HOC that redirects unauthenticated users. |
|
|
144
|
+
| `UserProvider` | Client (Layout) | Context provider for client-side state. |
|
|
145
|
+
| `useUser()` | Client (Hook) | Access `{ user, isLoading, error }` in components. |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT © UniDir
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unidir/unidir-nextjs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.tsx --format cjs,esm --dts --clean --minify --external react --external next",
|
|
20
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --external react,next"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"next": ">=13.0.0",
|
|
24
|
+
"react": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.0.3",
|
|
28
|
+
"@types/react": "^19.2.7",
|
|
29
|
+
"@types/react-dom": "^19.2.3",
|
|
30
|
+
"next": "^16.1.0",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"cookie": "^1.1.1",
|
|
36
|
+
"jose": "^6.1.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { createContext, useContext, useState, useEffect } from "react";
|
|
3
|
+
import { UniDirConfig } from ".";
|
|
4
|
+
import { verifyAccessToken } from "./jwks";
|
|
5
|
+
import { JWTPayload } from "jose";
|
|
6
|
+
|
|
7
|
+
const UserContext = createContext<{
|
|
8
|
+
user: any;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
config: UniDirConfig | null;
|
|
11
|
+
}>({ user: null, isLoading: true, config: null });
|
|
12
|
+
|
|
13
|
+
export function UserProvider({
|
|
14
|
+
children,
|
|
15
|
+
config,
|
|
16
|
+
}: {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
config: UniDirConfig;
|
|
19
|
+
}) {
|
|
20
|
+
const [user, setUser] = useState<JWTPayload | null>(null);
|
|
21
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
22
|
+
const [token, setToken] = useState(null);
|
|
23
|
+
const [accessToken, setAccessToken] = useState(null);
|
|
24
|
+
const [refreshToken, setRefreshToken] = useState(null);
|
|
25
|
+
const [idToken, setIdToken] = useState(null);
|
|
26
|
+
const [client, setClient] = useState(null);
|
|
27
|
+
const [expiresIn, setExpiresIn] = useState(null);
|
|
28
|
+
|
|
29
|
+
// useEffect(() => {
|
|
30
|
+
// fetch("/api/auth/me")
|
|
31
|
+
// .then((res) => res.json())
|
|
32
|
+
// .then((data) => {
|
|
33
|
+
// if (data) {
|
|
34
|
+
// setUser(data.client);
|
|
35
|
+
// setToken(data);
|
|
36
|
+
// setAccessToken(data.access_token);
|
|
37
|
+
// setRefreshToken(data.refresh_token);
|
|
38
|
+
// const idTokenAll = await jwtVerify(
|
|
39
|
+
// data.id_token,
|
|
40
|
+
// config.jwks || "https://oauth.biocloud.pro/jwks.json"
|
|
41
|
+
// );
|
|
42
|
+
// setIdToken(data.id_token);
|
|
43
|
+
// setClient(data.client);
|
|
44
|
+
// setExpiresIn(data.expres_in);
|
|
45
|
+
// }
|
|
46
|
+
// })
|
|
47
|
+
// .catch(() => setUser(null));
|
|
48
|
+
// }, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
async function loadUser() {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch("/api/auth/me");
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
//setUser(data.user);
|
|
55
|
+
const { companyId, domainId, email, email_verified, name } =
|
|
56
|
+
await verifyAccessToken(
|
|
57
|
+
data.id_token,
|
|
58
|
+
config.jwks || "https://oauth.igoodworks.com/jwks.json",
|
|
59
|
+
{
|
|
60
|
+
issuer: config.issuer || "http://oauth.unidir.igoodworks.com/",
|
|
61
|
+
audience: config.clientId,
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
setUser({ companyId, domainId, email, email_verified, name });
|
|
65
|
+
} finally {
|
|
66
|
+
setIsLoading(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
loadUser();
|
|
70
|
+
}, []);
|
|
71
|
+
return (
|
|
72
|
+
<UserContext.Provider
|
|
73
|
+
value={{
|
|
74
|
+
user,
|
|
75
|
+
isLoading,
|
|
76
|
+
config,
|
|
77
|
+
// token,
|
|
78
|
+
// expiresIn,
|
|
79
|
+
// accessToken,
|
|
80
|
+
// refreshToken,
|
|
81
|
+
// client,
|
|
82
|
+
// idToken,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</UserContext.Provider>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getDeviceId(): string {
|
|
91
|
+
if (typeof window === "undefined") return "server-default";
|
|
92
|
+
|
|
93
|
+
let id = localStorage.getItem("unidir_device_id");
|
|
94
|
+
if (!id) {
|
|
95
|
+
id = crypto.randomUUID();
|
|
96
|
+
localStorage.setItem("unidir_device_id", id);
|
|
97
|
+
}
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const useUser = () => useContext(UserContext);
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { parse } from "cookie";
|
|
2
|
+
import { encrypt, decrypt } from "./session";
|
|
3
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { generateCodeVerifier, generateCodeChallenge } from "./pkce";
|
|
6
|
+
|
|
7
|
+
export interface UniDirConfig {
|
|
8
|
+
domain: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientSecret: string;
|
|
11
|
+
secret: string;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
logoutRedirectUri?: string;
|
|
14
|
+
deviceId?: string;
|
|
15
|
+
scope?: string;
|
|
16
|
+
audience?: string;
|
|
17
|
+
jwks?: string;
|
|
18
|
+
issuer?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UniDirAction {
|
|
22
|
+
login: string;
|
|
23
|
+
loginPath: string;
|
|
24
|
+
logout: string;
|
|
25
|
+
callback: string;
|
|
26
|
+
me: string;
|
|
27
|
+
}
|
|
28
|
+
const defaultActions: UniDirAction = {
|
|
29
|
+
login: "login",
|
|
30
|
+
loginPath: "/api/auth/login",
|
|
31
|
+
logout: "logout",
|
|
32
|
+
callback: "callback",
|
|
33
|
+
me: "me",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function initUniDir(config: UniDirConfig, actions?: UniDirAction) {
|
|
37
|
+
const uniDirActions = { ...defaultActions, ...actions };
|
|
38
|
+
|
|
39
|
+
const getSession = async (req: Request | NextRequest) => {
|
|
40
|
+
const cookieHeader = req.headers.get("cookie") || "";
|
|
41
|
+
const cookies = parse(cookieHeader);
|
|
42
|
+
const sessionToken = cookies["unidir_session"];
|
|
43
|
+
if (!sessionToken) return null;
|
|
44
|
+
return await decrypt(sessionToken, config.secret);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
handleAuth: () => async (req: NextRequest) => {
|
|
49
|
+
const action = req.nextUrl.pathname.split("/").pop();
|
|
50
|
+
// Capture the deviceId from the query param sent by the LoginButton
|
|
51
|
+
const queryDeviceId = req.nextUrl.searchParams.get("device_id");
|
|
52
|
+
const effectiveDeviceId =
|
|
53
|
+
queryDeviceId || config.deviceId || "unknown-device";
|
|
54
|
+
//const deviceId = req.headers.get("x-device-id");
|
|
55
|
+
|
|
56
|
+
if (action === uniDirActions.login) {
|
|
57
|
+
const returnTo = req.nextUrl.searchParams.get("returnTo") || "/";
|
|
58
|
+
const verifier = generateCodeVerifier();
|
|
59
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
60
|
+
|
|
61
|
+
const url = new URL(`${config.domain}/authorize`);
|
|
62
|
+
url.searchParams.set("client_id", config.clientId);
|
|
63
|
+
url.searchParams.set("response_type", "code");
|
|
64
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
65
|
+
url.searchParams.set("scope", "openid profile email");
|
|
66
|
+
url.searchParams.set("code_challenge", challenge);
|
|
67
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
68
|
+
if (effectiveDeviceId) {
|
|
69
|
+
url.searchParams.set("device_id", effectiveDeviceId);
|
|
70
|
+
}
|
|
71
|
+
const response = NextResponse.redirect(url.toString());
|
|
72
|
+
|
|
73
|
+
// Store verifier in a short-lived, secure cookie
|
|
74
|
+
response.cookies.set("unidir_pkce_verifier", verifier, {
|
|
75
|
+
httpOnly: true,
|
|
76
|
+
secure: true,
|
|
77
|
+
sameSite: "lax",
|
|
78
|
+
maxAge: 60 * 5, // 5 minutes
|
|
79
|
+
});
|
|
80
|
+
response.cookies.set("unidir_device_id", effectiveDeviceId, {
|
|
81
|
+
httpOnly: true,
|
|
82
|
+
secure: true,
|
|
83
|
+
maxAge: 60 * 10, // 10 minutes
|
|
84
|
+
});
|
|
85
|
+
response.cookies.set("unidir_return_to", returnTo, {
|
|
86
|
+
httpOnly: true,
|
|
87
|
+
maxAge: 60 * 5,
|
|
88
|
+
});
|
|
89
|
+
return response;
|
|
90
|
+
}
|
|
91
|
+
// Profile Handler (for useUser hook)
|
|
92
|
+
if (action === uniDirActions.me) {
|
|
93
|
+
const session = await getSession(req);
|
|
94
|
+
if (!session)
|
|
95
|
+
return new NextResponse(JSON.stringify({ user: null }), {
|
|
96
|
+
status: 401,
|
|
97
|
+
});
|
|
98
|
+
return NextResponse.json(session);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Logout Handler
|
|
102
|
+
if (action === uniDirActions.logout) {
|
|
103
|
+
const response = NextResponse.redirect(new URL("/", req.url));
|
|
104
|
+
response.cookies.delete("unidir_session");
|
|
105
|
+
return response;
|
|
106
|
+
}
|
|
107
|
+
if (action === "callback") {
|
|
108
|
+
const code = req.nextUrl.searchParams.get("code");
|
|
109
|
+
const verifier = req.cookies.get("unidir_pkce_verifier")?.value;
|
|
110
|
+
|
|
111
|
+
if (!code || !verifier) {
|
|
112
|
+
return new NextResponse("Missing code or verifier", { status: 400 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const storedDeviceId = req.cookies.get("unidir_device_id")?.value;
|
|
116
|
+
const deviceIdToUse = storedDeviceId || effectiveDeviceId;
|
|
117
|
+
|
|
118
|
+
const res = await fetch(`${config.domain}/token`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: {
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
"x-device-id": deviceIdToUse, // Added to header
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
grant_type: "authorization_code",
|
|
126
|
+
client_id: config.clientId,
|
|
127
|
+
client_secret: config.clientSecret,
|
|
128
|
+
code,
|
|
129
|
+
code_verifier: verifier, // Pass the verifier back
|
|
130
|
+
redirect_uri: config.redirectUri,
|
|
131
|
+
device_id: deviceIdToUse,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const tokens = await res.json();
|
|
136
|
+
if (tokens.error) return NextResponse.json(tokens, { status: 400 });
|
|
137
|
+
|
|
138
|
+
const encryptedSession = await encrypt(tokens, config.secret);
|
|
139
|
+
// const response = NextResponse.redirect(new URL("/", req.url));
|
|
140
|
+
const returnTo = req.cookies.get("unidir_return_to")?.value || "/";
|
|
141
|
+
const response = NextResponse.redirect(new URL(returnTo, req.url));
|
|
142
|
+
|
|
143
|
+
// Clean up PKCE cookie and set session
|
|
144
|
+
response.cookies.delete("unidir_pkce_verifier");
|
|
145
|
+
response.cookies.set("unidir_session", encryptedSession, {
|
|
146
|
+
httpOnly: true,
|
|
147
|
+
secure: true,
|
|
148
|
+
sameSite: "lax",
|
|
149
|
+
maxAge: 60 * 60 * 24,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return response;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return new NextResponse("Not Found", { status: 404 });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
getSession,
|
|
159
|
+
|
|
160
|
+
withPageAuthRequired: <P extends object>(
|
|
161
|
+
Component: React.ComponentType<P>
|
|
162
|
+
) => {
|
|
163
|
+
return async (props: P) => {
|
|
164
|
+
// Import headers dynamically to avoid issues in non-server environments
|
|
165
|
+
const { headers } = await import("next/headers");
|
|
166
|
+
const session = await getSession({ headers: await headers() } as any);
|
|
167
|
+
|
|
168
|
+
if (!session) {
|
|
169
|
+
redirect(uniDirActions.loginPath);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Return the component as JSX
|
|
173
|
+
return <Component {...props} user={session} />;
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
withMiddlewareAuth: () => {
|
|
178
|
+
return async (req: NextRequest) => {
|
|
179
|
+
const sessionToken = req.cookies.get("unidir_session")?.value;
|
|
180
|
+
const session = sessionToken
|
|
181
|
+
? await decrypt(sessionToken, config.secret)
|
|
182
|
+
: null;
|
|
183
|
+
|
|
184
|
+
if (!session) {
|
|
185
|
+
// Redirect to login but save the current URL to return back later
|
|
186
|
+
const { pathname, search } = req.nextUrl;
|
|
187
|
+
const url = new URL(uniDirActions.loginPath, req.url);
|
|
188
|
+
url.searchParams.set("returnTo", `${pathname}${search}`);
|
|
189
|
+
return NextResponse.redirect(url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return NextResponse.next();
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { UserProvider, useUser } from "./client";
|
package/src/jwks.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jwtVerify, createRemoteJWKSet } from "jose";
|
|
2
|
+
|
|
3
|
+
// Replace with your IdP issuer and audience
|
|
4
|
+
const ISSUER = "https://YOUR_ISSUER/";
|
|
5
|
+
const AUDIENCE = "YOUR_CLIENT_ID";
|
|
6
|
+
|
|
7
|
+
export async function verifyAccessToken(
|
|
8
|
+
token: string,
|
|
9
|
+
jwksUrl: string,
|
|
10
|
+
options: Record<string, any> = {}
|
|
11
|
+
) {
|
|
12
|
+
const JWKS = createRemoteJWKSet(new URL(jwksUrl));
|
|
13
|
+
const { payload } = await jwtVerify(token, JWKS, {
|
|
14
|
+
issuer: options.issuer,
|
|
15
|
+
audience: options.audience,
|
|
16
|
+
});
|
|
17
|
+
// Optionally apply custom claims checks here (e.g., roles, scopes)
|
|
18
|
+
return payload;
|
|
19
|
+
}
|
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function generateCodeVerifier(): string {
|
|
2
|
+
const array = new Uint8Array(32);
|
|
3
|
+
crypto.getRandomValues(array);
|
|
4
|
+
return btoa(String.fromCharCode(...array))
|
|
5
|
+
.replace(/\+/g, "-")
|
|
6
|
+
.replace(/\//g, "_")
|
|
7
|
+
.replace(/=/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const data = encoder.encode(verifier);
|
|
13
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
14
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
15
|
+
.replace(/\+/g, "-")
|
|
16
|
+
.replace(/\//g, "_")
|
|
17
|
+
.replace(/=/g, "");
|
|
18
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EncryptJWT, jwtDecrypt } from "jose";
|
|
2
|
+
|
|
3
|
+
const getSecretKey = (secret: string) =>
|
|
4
|
+
new TextEncoder().encode(secret.padEnd(32, "0").slice(0, 32));
|
|
5
|
+
|
|
6
|
+
export async function encrypt(payload: any, secret: string) {
|
|
7
|
+
return new EncryptJWT(payload)
|
|
8
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
|
9
|
+
.setIssuedAt()
|
|
10
|
+
.setExpirationTime("24h")
|
|
11
|
+
.encrypt(getSecretKey(secret));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function decrypt(token: string, secret: string) {
|
|
15
|
+
try {
|
|
16
|
+
const { payload } = await jwtDecrypt(token, getSecretKey(secret));
|
|
17
|
+
return payload;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"strict": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|