@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 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
+ }