@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.
- package/dist/files/lib/auth/client/index.ts +2 -0
- package/dist/files/lib/auth/client/provider.tsx +424 -0
- package/dist/files/lib/auth/config.ts +54 -0
- package/dist/files/lib/auth/core/config.ts +57 -0
- package/dist/files/lib/auth/core/cookies.ts +92 -0
- package/dist/files/lib/auth/core/index.ts +14 -0
- package/dist/files/lib/auth/core/jwt.ts +65 -0
- package/dist/files/lib/auth/index.ts +112 -0
- package/dist/files/lib/auth/middleware/auth-middleware.ts +191 -0
- package/dist/files/lib/auth/middleware/index.ts +3 -0
- package/dist/files/lib/auth/server/actions.ts +352 -0
- package/dist/files/lib/auth/server/fetchers.ts +40 -0
- package/dist/files/lib/auth/server/index.ts +12 -0
- package/dist/files/lib/auth/server/session.ts +158 -0
- package/dist/files/lib/auth/types.ts +227 -0
- package/dist/index.js +1128 -0
- package/package.json +41 -0
|
@@ -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
|
+
}
|