@smittdev/next-jwt-auth 0.1.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.
@@ -0,0 +1,65 @@
1
+ import type { TokenPayload } from "../types";
2
+ import { TokenPayloadSchema } from "../types";
3
+
4
+ /**
5
+ * Decodes a JWT without verifying the signature.
6
+ * Verification is the responsibility of your API — we only need the payload
7
+ * for expiry checks and user data hydration.
8
+ *
9
+ * Returns null if the token is malformed or cannot be parsed.
10
+ */
11
+ export function decodeJwt(token: string): TokenPayload | null {
12
+ try {
13
+ const segments = token.split(".");
14
+ if (segments.length !== 3) return null;
15
+
16
+ const payloadSegment = segments[1];
17
+ const base64 = payloadSegment
18
+ .replace(/-/g, "+")
19
+ .replace(/_/g, "/")
20
+ .padEnd(
21
+ payloadSegment.length + ((4 - (payloadSegment.length % 4)) % 4),
22
+ "=",
23
+ );
24
+
25
+ const jsonString = Buffer.from(base64, "base64").toString("utf-8");
26
+ const payload = JSON.parse(jsonString) as unknown;
27
+
28
+ if (typeof payload !== "object" || payload === null) return null;
29
+ return payload as TokenPayload;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export interface TokenExpiryInfo {
36
+ maxAgeSeconds: number;
37
+ expiresAt: Date;
38
+ isExpired: boolean;
39
+ }
40
+
41
+ export function getTokenExpiry(token: string): TokenExpiryInfo | null {
42
+ const payload = decodeJwt(token);
43
+ if (!payload) return null;
44
+
45
+ const validated = TokenPayloadSchema.safeParse(payload);
46
+ if (!validated.success) return null;
47
+
48
+ const { exp } = validated.data;
49
+ const expiresAt = new Date(exp * 1000);
50
+ const nowMs = Date.now();
51
+ const maxAgeSeconds = Math.floor((expiresAt.getTime() - nowMs) / 1000);
52
+
53
+ return { maxAgeSeconds, expiresAt, isExpired: maxAgeSeconds <= 0 };
54
+ }
55
+
56
+ export function isTokenValid(token: string): boolean {
57
+ const expiry = getTokenExpiry(token);
58
+ return expiry !== null && !expiry.isExpired;
59
+ }
60
+
61
+ export function getSecondsUntilExpiry(token: string): number {
62
+ const expiry = getTokenExpiry(token);
63
+ if (!expiry) return 0;
64
+ return Math.max(0, expiry.maxAgeSeconds);
65
+ }
@@ -0,0 +1,112 @@
1
+ // lib/auth/index.ts
2
+ //
3
+ // ⚠️ SERVER-ONLY — DO NOT IMPORT THIS FILE IN CLIENT COMPONENTS
4
+ //
5
+ // This is the main entry point for the library. It exports the Auth() factory
6
+ // and all server-side utilities. Client-side usage goes through:
7
+ // import { AuthProvider, useSession, useAuth } from "@/lib/auth/client"
8
+
9
+ import type { AuthActions, AuthConfig } from "./types";
10
+ import { createAuthConfig } from "./core/config";
11
+ import { setGlobalAuthConfig } from "./config";
12
+ import {
13
+ getSession,
14
+ getUser,
15
+ getAccessToken,
16
+ getRefreshToken,
17
+ requireSession,
18
+ } from "./server/session";
19
+ import { withSession, withRequiredSession } from "./server/fetchers";
20
+ import {
21
+ createAuthMiddleware,
22
+ matchesPath,
23
+ } from "./middleware/auth-middleware";
24
+ import { fetchSessionAction, loginAction, logoutAction, updateSessionTokenAction } from "./server/actions";
25
+
26
+ /**
27
+ * Initializes the auth library with your adapter and configuration.
28
+ *
29
+ * Call this once in `auth.ts` at your project root. The resolved config is
30
+ * stored in a module-level singleton so every internal module can access it
31
+ * without prop drilling.
32
+ *
33
+ * @example
34
+ * // auth.ts
35
+ * import { Auth } from "@/lib/auth";
36
+ *
37
+ * export const auth = Auth({
38
+ * adapter: {
39
+ * async login(credentials) { ... },
40
+ * async refreshToken(token) { ... },
41
+ * async fetchUser(accessToken) { ... },
42
+ * },
43
+ * debug: process.env.NODE_ENV === "development",
44
+ * });
45
+ */
46
+ export function Auth(config: AuthConfig) {
47
+ const resolved = createAuthConfig(config);
48
+
49
+ // Store in the module-level singleton — every internal call to
50
+ // getGlobalAuthConfig() will return this resolved config.
51
+ setGlobalAuthConfig(resolved);
52
+
53
+ return {
54
+ // ── Server-side session helpers ────────────────────────────────────────
55
+ /** Returns the current session, or null if unauthenticated. */
56
+ getSession,
57
+ /** Returns the current user, or null if unauthenticated. */
58
+ getUser,
59
+ /** Returns the current access token, or null if unauthenticated. */
60
+ getAccessToken,
61
+ /** Returns the current refresh token, or null if unauthenticated. */
62
+ getRefreshToken,
63
+ /** Returns the current session, or redirects to the sign-in page. */
64
+ requireSession,
65
+
66
+ // ── Fetch utilities ────────────────────────────────────────────────────
67
+ /** Run a callback with the session if it exists, otherwise return null. */
68
+ withSession,
69
+ /** Run a callback with the session, or redirect to sign-in. */
70
+ withRequiredSession,
71
+ // ── Middleware ─────────────────────────────────────────────────────────
72
+ /** Returns a middleware resolver function for use in middleware.ts. */
73
+ createMiddleware: () => createAuthMiddleware(),
74
+ /** Returns true if pathname matches any of the given path patterns. */
75
+ matchesPath,
76
+
77
+ // ── Config ─────────────────────────────────────────────────────────────
78
+ /** The resolved configuration object (rarely needed directly). */
79
+ config: resolved,
80
+
81
+ // ── Server Actions ─────────────────────────────────────────────────────
82
+ // Bundled here so your root layout can pass them to <AuthProvider>.
83
+ // Since auth.ts is imported by layout.tsx, the singleton is guaranteed
84
+ // to be initialized before any of these actions ever run.
85
+ actions: {
86
+ login: loginAction,
87
+ logout: logoutAction,
88
+ fetchSession: fetchSessionAction,
89
+ updateSessionToken: updateSessionTokenAction,
90
+ } satisfies AuthActions,
91
+ };
92
+ }
93
+
94
+ // ─── Re-exports ───────────────────────────────────────────────────────────────
95
+ // These let consumers do: import type { SessionUser } from "@/lib/auth"
96
+
97
+ export type {
98
+ AuthConfig,
99
+ AuthAdapter,
100
+ AuthPages,
101
+ CookieOptions,
102
+ RefreshOptions,
103
+ Session,
104
+ SessionUser,
105
+ TokenPair,
106
+ ClientSession,
107
+ SessionStatus,
108
+ ActionResult,
109
+ SessionActionData,
110
+ AuthActions,
111
+ LoginActionOptions,
112
+ } from "./types";
@@ -0,0 +1,191 @@
1
+ // lib/auth/middleware/auth-middleware.ts
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { getSecondsUntilExpiry, isTokenValid } from "../core/jwt";
4
+ import { getGlobalAuthConfig, debugLog } from "../config";
5
+
6
+ export interface AuthMiddlewareResult {
7
+ /** True if the request has a valid, non-expired access token. */
8
+ isAuthenticated: boolean;
9
+ /** The current access token (may be a freshly rotated one). */
10
+ accessToken: string | null;
11
+ /** The current refresh token (may be a freshly rotated one). */
12
+ refreshToken: string | null;
13
+ /**
14
+ * Wraps a NextResponse, writing any refreshed token cookies onto it.
15
+ * Always use this instead of returning the response directly.
16
+ *
17
+ * @example
18
+ * return session.response(NextResponse.next());
19
+ * return session.response(NextResponse.redirect(url));
20
+ */
21
+ response: (base: NextResponse) => NextResponse;
22
+ /**
23
+ * Redirects to a URL and clears the session cookies.
24
+ * Use this when redirecting unauthenticated users to the login page.
25
+ *
26
+ * @example
27
+ * return session.redirect(new URL("/login", request.url));
28
+ */
29
+ redirect: (url: URL) => NextResponse;
30
+ }
31
+
32
+ /**
33
+ * Converts a path pattern string to a regex.
34
+ * Supports :param and :path* wildcards.
35
+ *
36
+ * Examples:
37
+ * "/dashboard/:path*" → matches /dashboard, /dashboard/settings, etc.
38
+ * "/user/:id" → matches /user/123 but not /user/123/profile
39
+ */
40
+ function patternToRegex(pattern: string): RegExp {
41
+ const escaped = pattern
42
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
43
+ .replace(/\/:[\w]+\*/g, "(?:/.*)?")
44
+ .replace(/:[\w]+\*/g, ".*")
45
+ .replace(/:[\w]+/g, "[^/]+");
46
+ return new RegExp(`^${escaped}\\/?$`);
47
+ }
48
+
49
+ /**
50
+ * Returns true if `pathname` matches any of the provided path patterns.
51
+ *
52
+ * @example
53
+ * auth.matchesPath("/dashboard/settings", ["/dashboard/:path*"]) // true
54
+ * auth.matchesPath("/login", ["/", "/login"]) // true
55
+ */
56
+ export function matchesPath(pathname: string, patterns: string[]): boolean {
57
+ return patterns.some((pattern) => patternToRegex(pattern).test(pathname));
58
+ }
59
+
60
+ function writeTokensToResponse(
61
+ response: NextResponse,
62
+ accessToken: string,
63
+ refreshToken: string,
64
+ ): void {
65
+ const config = getGlobalAuthConfig();
66
+ const accessExpiry = getSecondsUntilExpiry(accessToken);
67
+ const refreshExpiry = getSecondsUntilExpiry(refreshToken);
68
+
69
+ const baseOptions = {
70
+ httpOnly: true,
71
+ secure: config.cookieOptions.secure,
72
+ sameSite: config.cookieOptions.sameSite as "strict" | "lax" | "none",
73
+ path: config.cookieOptions.path,
74
+ ...(config.cookieOptions.domain
75
+ ? { domain: config.cookieOptions.domain }
76
+ : {}),
77
+ };
78
+
79
+ response.cookies.set(config.cookieNames.accessToken, accessToken, {
80
+ ...baseOptions,
81
+ ...(accessExpiry > 0 ? { maxAge: accessExpiry } : {}),
82
+ });
83
+
84
+ response.cookies.set(config.cookieNames.refreshToken, refreshToken, {
85
+ ...baseOptions,
86
+ ...(refreshExpiry > 0 ? { maxAge: refreshExpiry } : {}),
87
+ });
88
+ }
89
+
90
+ function clearTokensFromResponse(response: NextResponse): void {
91
+ const config = getGlobalAuthConfig();
92
+ response.cookies.set(config.cookieNames.accessToken, "", { maxAge: 0 });
93
+ response.cookies.set(config.cookieNames.refreshToken, "", { maxAge: 0 });
94
+ }
95
+
96
+ /**
97
+ * Returns an async middleware resolver function.
98
+ * Call this once per middleware invocation to get the session state.
99
+ *
100
+ * The resolver automatically handles token refresh — if the access token
101
+ * is expired or close to expiry, it calls adapter.refreshToken() and
102
+ * returns the new tokens. Use session.response() to write them to cookies.
103
+ */
104
+ export function createAuthMiddleware() {
105
+ return async function resolveAuth(
106
+ request: NextRequest,
107
+ ): Promise<AuthMiddlewareResult> {
108
+ const config = getGlobalAuthConfig();
109
+ const { pathname } = request.nextUrl;
110
+
111
+ let accessToken =
112
+ request.cookies.get(config.cookieNames.accessToken)?.value ?? null;
113
+ const refreshToken =
114
+ request.cookies.get(config.cookieNames.refreshToken)?.value ?? null;
115
+
116
+ let refreshedTokens: { accessToken: string; refreshToken: string } | null =
117
+ null;
118
+
119
+ if (!accessToken && !refreshToken) {
120
+ debugLog("Middleware: no tokens found", { pathname });
121
+ }
122
+
123
+ // Refresh if: no access token, expired, or within the refresh threshold
124
+ const secondsRemaining = accessToken
125
+ ? getSecondsUntilExpiry(accessToken)
126
+ : 0;
127
+ const needsRefresh =
128
+ !accessToken ||
129
+ !isTokenValid(accessToken) ||
130
+ secondsRemaining <= config.refreshThresholdSeconds;
131
+
132
+ if (needsRefresh && refreshToken && isTokenValid(refreshToken)) {
133
+ debugLog("Middleware: access token needs refresh — attempting", {
134
+ pathname,
135
+ reason: !accessToken
136
+ ? "no access token"
137
+ : !isTokenValid(accessToken)
138
+ ? "access token expired"
139
+ : `within threshold (${secondsRemaining}s remaining)`,
140
+ });
141
+
142
+ try {
143
+ refreshedTokens = await config.adapter.refreshToken(refreshToken);
144
+ accessToken = refreshedTokens.accessToken;
145
+ debugLog("Middleware: token refresh successful", { pathname });
146
+ } catch (error) {
147
+ // Refresh failed — proceed with the existing (potentially expired) token state
148
+ debugLog(
149
+ "Middleware: token refresh failed — proceeding with existing state",
150
+ {
151
+ pathname,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ },
154
+ );
155
+ }
156
+ }
157
+
158
+ const isAuthenticated = accessToken ? isTokenValid(accessToken) : false;
159
+
160
+ debugLog("Middleware: resolved", { pathname, isAuthenticated });
161
+
162
+ function response(base: NextResponse): NextResponse {
163
+ if (refreshedTokens) {
164
+ writeTokensToResponse(
165
+ base,
166
+ refreshedTokens.accessToken,
167
+ refreshedTokens.refreshToken,
168
+ );
169
+ }
170
+ return base;
171
+ }
172
+
173
+ function redirect(url: URL): NextResponse {
174
+ debugLog("Middleware: redirecting and clearing session cookies", {
175
+ pathname,
176
+ destination: url.pathname,
177
+ });
178
+ const redirectResponse = NextResponse.redirect(url);
179
+ clearTokensFromResponse(redirectResponse);
180
+ return redirectResponse;
181
+ }
182
+
183
+ return {
184
+ isAuthenticated,
185
+ accessToken,
186
+ refreshToken: refreshedTokens?.refreshToken ?? refreshToken,
187
+ response,
188
+ redirect,
189
+ };
190
+ };
191
+ }
@@ -0,0 +1,3 @@
1
+ // lib/auth/middleware/index.ts
2
+ export { createAuthMiddleware, matchesPath } from "./auth-middleware";
3
+ export type { AuthMiddlewareResult } from "./auth-middleware";