@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,352 @@
1
+ "use server";
2
+
3
+ import { redirect } from "next/navigation";
4
+ import {
5
+ clearTokenCookies,
6
+ getTokensFromCookies,
7
+ isTokenValid,
8
+ setTokenCookies,
9
+ } from "../core";
10
+ import type {
11
+ ActionResult,
12
+ LoginActionOptions,
13
+ SessionActionData,
14
+ } from "../types";
15
+ import { TokenPairSchema } from "../types";
16
+ import { getGlobalAuthConfig, debugLog } from "../config";
17
+
18
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
19
+
20
+ function extractErrorMessage(error: unknown, fallback: string): string {
21
+ if (error instanceof Error) return error.message;
22
+ return fallback;
23
+ }
24
+
25
+ function validateTokenPair(tokens: unknown) {
26
+ return TokenPairSchema.parse(tokens);
27
+ }
28
+
29
+ function isNextRedirectError(error: unknown): boolean {
30
+ return (
31
+ typeof error === "object" &&
32
+ error !== null &&
33
+ "digest" in error &&
34
+ typeof (error as Record<string, unknown>).digest === "string" &&
35
+ ((error as Record<string, unknown>).digest as string).startsWith(
36
+ "NEXT_REDIRECT",
37
+ )
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Validates a callbackUrl to prevent open-redirect attacks.
43
+ * Only root-relative paths (starting with "/" but not "//") are allowed.
44
+ * Any other value — absolute URLs, protocol-relative URLs, empty strings —
45
+ * is rejected and returns undefined, falling back to pages.home.
46
+ */
47
+ function sanitizeCallbackUrl(url: string | undefined): string | undefined {
48
+ if (!url) return undefined;
49
+ // Allow root-relative paths only — blocks `//evil.com` and `https://evil.com`
50
+ if (url.startsWith("/") && !url.startsWith("//")) return url;
51
+ debugLog("sanitizeCallbackUrl: rejected unsafe callbackUrl", { url });
52
+ return undefined;
53
+ }
54
+
55
+ // ─── Actions ─────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Fetches the current session from cookies and returns fresh session data.
59
+ *
60
+ * If the access token is expired but a valid refresh token exists, this action
61
+ * silently rotates the token pair (calls adapter.refreshToken, updates cookies)
62
+ * before resolving the session — the caller always receives a valid, up-to-date
63
+ * session or null, never a stale/expired one.
64
+ *
65
+ * Returns { success: true, data: null } if no valid session exists at all.
66
+ *
67
+ * Used internally by AuthProvider on mount and on tab focus via fetchSession.
68
+ */
69
+ export async function fetchSessionAction(): Promise<
70
+ ActionResult<SessionActionData | null>
71
+ > {
72
+ debugLog("fetchSessionAction: called");
73
+
74
+ try {
75
+ const config = getGlobalAuthConfig();
76
+ const tokens = await getTokensFromCookies(config);
77
+
78
+ if (!tokens) {
79
+ debugLog("fetchSessionAction: no tokens found in cookies");
80
+ return { success: true, data: null };
81
+ }
82
+
83
+ let { accessToken, refreshToken } = tokens;
84
+
85
+ if (!isTokenValid(accessToken)) {
86
+ debugLog("fetchSessionAction: access token invalid — attempting refresh");
87
+
88
+ if (!isTokenValid(refreshToken)) {
89
+ debugLog(
90
+ "fetchSessionAction: refresh token also invalid — clearing cookies",
91
+ );
92
+ await clearTokenCookies(config);
93
+ return { success: true, data: null };
94
+ }
95
+
96
+ try {
97
+ const refreshed = await config.adapter.refreshToken(refreshToken);
98
+ const validated = validateTokenPair(refreshed);
99
+ await setTokenCookies(validated, config);
100
+ accessToken = validated.accessToken;
101
+ refreshToken = validated.refreshToken;
102
+ debugLog("fetchSessionAction: token refresh successful");
103
+ } catch (refreshError) {
104
+ debugLog(
105
+ "fetchSessionAction: token refresh failed — clearing cookies",
106
+ {
107
+ error:
108
+ refreshError instanceof Error
109
+ ? refreshError.message
110
+ : String(refreshError),
111
+ },
112
+ );
113
+ await clearTokenCookies(config);
114
+ return { success: true, data: null };
115
+ }
116
+ }
117
+
118
+ const user = await config.adapter.fetchUser(accessToken);
119
+ debugLog("fetchSessionAction: session resolved", { userId: user.id });
120
+ return { success: true, data: { accessToken, refreshToken, user } };
121
+ } catch (error) {
122
+ debugLog("fetchSessionAction: unexpected error", {
123
+ error: error instanceof Error ? error.message : String(error),
124
+ });
125
+ return {
126
+ success: false,
127
+ error: extractErrorMessage(error, "Failed to fetch session."),
128
+ };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Logs the user in with the provided credentials.
134
+ *
135
+ * By default (`redirect: true`) redirects after a successful login using
136
+ * the first truthy value in this priority order:
137
+ * 1. `options.redirectTo` — explicit override, always wins
138
+ * 2. `options.callbackUrl` — typically the `?callbackUrl=` param set by
139
+ * requireSession(); validated to prevent open-redirect attacks
140
+ * 3. `config.pages.home` — the configured default
141
+ *
142
+ * Set `redirect: false` to disable the automatic redirect and handle
143
+ * navigation yourself on the client based on the returned ActionResult.
144
+ *
145
+ * @example
146
+ * // Default — redirect to pages.home
147
+ * await loginAction({ email, password });
148
+ *
149
+ * // Honour the callbackUrl from the URL search params
150
+ * const result = await loginAction(
151
+ * { email, password },
152
+ * { callbackUrl: searchParams.get("callbackUrl") ?? undefined },
153
+ * );
154
+ *
155
+ * // Disable redirect entirely — handle navigation on the client
156
+ * const result = await loginAction(
157
+ * { email, password },
158
+ * { redirect: false },
159
+ * );
160
+ * if (result.success) router.push("/dashboard");
161
+ * else setError(result.error);
162
+ */
163
+ export async function loginAction(
164
+ credentials: Record<string, unknown>,
165
+ options: LoginActionOptions = {},
166
+ ): Promise<ActionResult<SessionActionData>> {
167
+ const { redirect: shouldRedirect = true, redirectTo, callbackUrl } = options;
168
+
169
+ debugLog("loginAction: called", {
170
+ shouldRedirect,
171
+ hasCallbackUrl: !!callbackUrl,
172
+ });
173
+
174
+ try {
175
+ const config = getGlobalAuthConfig();
176
+ const rawTokens = await config.adapter.login(credentials);
177
+ const tokens = validateTokenPair(rawTokens);
178
+ await setTokenCookies(tokens, config);
179
+ const user = await config.adapter.fetchUser(tokens.accessToken);
180
+
181
+ debugLog("loginAction: login successful", { userId: user.id });
182
+
183
+ const result: ActionResult<SessionActionData> = {
184
+ success: true,
185
+ data: {
186
+ accessToken: tokens.accessToken,
187
+ refreshToken: tokens.refreshToken,
188
+ user,
189
+ },
190
+ };
191
+
192
+ if (shouldRedirect) {
193
+ // Priority: explicit redirectTo > sanitized callbackUrl > configured default
194
+ const destination =
195
+ redirectTo ??
196
+ sanitizeCallbackUrl(callbackUrl) ??
197
+ config.pages.home;
198
+
199
+ debugLog("loginAction: redirecting", { destination });
200
+ // redirect() throws internally — this line never returns
201
+ redirect(destination);
202
+ }
203
+
204
+ return result;
205
+ } catch (error) {
206
+ if (isNextRedirectError(error)) throw error;
207
+
208
+ debugLog("loginAction: login failed", {
209
+ error: error instanceof Error ? error.message : String(error),
210
+ });
211
+
212
+ const config = getGlobalAuthConfig();
213
+ await clearTokenCookies(config).catch(() => {});
214
+ return {
215
+ success: false,
216
+ error: extractErrorMessage(error, "Login failed. Please try again."),
217
+ };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Logs the user out.
223
+ *
224
+ * By default (`redirect: true`) clears cookies and redirects to `redirectTo`
225
+ * or `pages.signIn` — matching next-auth behaviour.
226
+ *
227
+ * Set `redirect: false` to disable the automatic redirect and handle
228
+ * navigation yourself on the client based on the returned ActionResult.
229
+ *
230
+ * @example
231
+ * // Default — redirect happens automatically
232
+ * await logoutAction();
233
+ *
234
+ * // Custom redirect target
235
+ * await logoutAction({ redirectTo: "/login" });
236
+ *
237
+ * // Disable redirect — handle it on the client
238
+ * const result = await logoutAction({ redirect: false });
239
+ * if (result.success) router.replace("/");
240
+ */
241
+ export async function logoutAction(
242
+ options: { redirect?: boolean; redirectTo?: string } = {},
243
+ ): Promise<ActionResult<null>> {
244
+ const { redirect: shouldRedirect = true, redirectTo } = options;
245
+
246
+ debugLog("logoutAction: called", { shouldRedirect });
247
+
248
+ const config = getGlobalAuthConfig();
249
+
250
+ try {
251
+ const tokens = await getTokensFromCookies(config);
252
+
253
+ if (tokens && config.adapter.logout) {
254
+ try {
255
+ await config.adapter.logout(tokens);
256
+ debugLog("logoutAction: adapter.logout() completed");
257
+ } catch (adapterError) {
258
+ // Non-fatal — cookies will still be cleared regardless
259
+ debugLog(
260
+ "logoutAction: adapter.logout() threw — cookies will still be cleared",
261
+ {
262
+ error:
263
+ adapterError instanceof Error
264
+ ? adapterError.message
265
+ : String(adapterError),
266
+ },
267
+ );
268
+ console.error(
269
+ "[next-jwt-auth] logoutAction: adapter.logout() threw. Cookies will still be cleared.",
270
+ adapterError,
271
+ );
272
+ }
273
+ }
274
+
275
+ await clearTokenCookies(config);
276
+ debugLog("logoutAction: cookies cleared");
277
+ } catch (error) {
278
+ if (isNextRedirectError(error)) throw error;
279
+ debugLog("logoutAction: unexpected error", {
280
+ error: error instanceof Error ? error.message : String(error),
281
+ });
282
+ return {
283
+ success: false,
284
+ error: extractErrorMessage(error, "Logout failed. Please try again."),
285
+ };
286
+ }
287
+
288
+ if (shouldRedirect) {
289
+ const destination = redirectTo ?? config.pages.signIn;
290
+ debugLog("logoutAction: redirecting", { destination });
291
+ // redirect() is called outside try/catch so it is never swallowed
292
+ redirect(destination);
293
+ }
294
+
295
+ return { success: true, data: null };
296
+ }
297
+
298
+ /**
299
+ * Updates the session's access token manually.
300
+ *
301
+ * This is useful when the user is handling token refreshes on the client side
302
+ * via an Axios interceptor (or similar) instead of relying on the Next.js middleware.
303
+ * If the user's external API returns a new access token, they can call this action
304
+ * to sync it into the HttpOnly cookies so Server Components can see it.
305
+ */
306
+ export async function updateSessionTokenAction(
307
+ newAccessToken: string,
308
+ ): Promise<ActionResult<SessionActionData>> {
309
+ debugLog("updateSessionTokenAction: called");
310
+
311
+ if (!newAccessToken || typeof newAccessToken !== "string") {
312
+ return { success: false, error: "Invalid access token provided." };
313
+ }
314
+
315
+ try {
316
+ const config = getGlobalAuthConfig();
317
+ const tokens = await getTokensFromCookies(config);
318
+
319
+ if (!tokens) {
320
+ return { success: false, error: "No active session to update." };
321
+ }
322
+
323
+ const newTokens = {
324
+ accessToken: newAccessToken,
325
+ refreshToken: tokens.refreshToken,
326
+ };
327
+
328
+ const validated = validateTokenPair(newTokens);
329
+ await setTokenCookies(validated, config);
330
+ const user = await config.adapter.fetchUser(validated.accessToken);
331
+
332
+ debugLog("updateSessionTokenAction: session token updated", { userId: user.id });
333
+
334
+ return {
335
+ success: true,
336
+ data: {
337
+ accessToken: validated.accessToken,
338
+ refreshToken: validated.refreshToken,
339
+ user,
340
+ },
341
+ };
342
+ } catch (error) {
343
+ debugLog("updateSessionTokenAction: unexpected error", {
344
+ error: error instanceof Error ? error.message : String(error),
345
+ });
346
+ return {
347
+ success: false,
348
+ error: extractErrorMessage(error, "Failed to update session token."),
349
+ };
350
+ }
351
+ }
352
+
@@ -0,0 +1,40 @@
1
+ // lib/auth/server/fetchers.ts
2
+ import { redirect } from "next/navigation";
3
+ import type { Session } from "../types";
4
+ import { getSession } from "./session";
5
+ import { getGlobalAuthConfig } from "../config";
6
+
7
+ /**
8
+ * Runs a callback with the current session if one exists.
9
+ * Returns null (or the provided defaultValue) if the user is not authenticated.
10
+ *
11
+ * @example
12
+ * const data = await auth.withSession((session) => fetchUserData(session.accessToken));
13
+ */
14
+ export async function withSession<TResult>(
15
+ callback: (session: Session) => TResult | Promise<TResult>,
16
+ defaultValue?: TResult,
17
+ ): Promise<TResult | null> {
18
+ const session = await getSession();
19
+ if (!session) {
20
+ return defaultValue !== undefined ? defaultValue : null;
21
+ }
22
+ return callback(session);
23
+ }
24
+
25
+ /**
26
+ * Runs a callback with the current session, or redirects to the sign-in page.
27
+ * Use in Server Components or server actions where authentication is required.
28
+ *
29
+ * @example
30
+ * const data = await auth.withRequiredSession((session) => fetchProfile(session.user.id));
31
+ */
32
+ export async function withRequiredSession<TResult>(
33
+ callback: (session: Session) => TResult | Promise<TResult>,
34
+ ): Promise<TResult> {
35
+ const config = getGlobalAuthConfig();
36
+ const session = await getSession();
37
+ if (!session) redirect(config.pages.signIn);
38
+ return callback(session);
39
+ }
40
+
@@ -0,0 +1,12 @@
1
+ // lib/auth/server/index.ts
2
+ export {
3
+ getSession,
4
+ getAccessToken,
5
+ getRefreshToken,
6
+ getUser,
7
+ requireSession,
8
+ } from "./session";
9
+
10
+ export { fetchSessionAction, loginAction, logoutAction } from "./actions";
11
+
12
+ export { withSession, withRequiredSession } from "./fetchers";
@@ -0,0 +1,158 @@
1
+ // lib/auth/server/session.ts
2
+ import { cache } from "react";
3
+ import { redirect } from "next/navigation";
4
+ import { headers } from "next/headers";
5
+ import { getTokensFromCookies, isTokenValid } from "../core";
6
+ import type { Session, SessionUser } from "../types";
7
+ import { getGlobalAuthConfig, debugLog } from "../config";
8
+
9
+ /**
10
+ * Module-level cached resolver. React's `cache()` deduplicates this per request,
11
+ * so calling `getSession()` multiple times in one render tree costs exactly one
12
+ * cookie read and one adapter.fetchUser() call.
13
+ *
14
+ * Intentionally does NOT refresh tokens or write cookies — that is handled by
15
+ * the middleware before the request reaches this point. Attempting to set cookies
16
+ * during page rendering throws in Next.js (only allowed in Server Actions /
17
+ * Route Handlers).
18
+ */
19
+ const resolveSession = cache(async (): Promise<Session | null> => {
20
+ const config = getGlobalAuthConfig();
21
+ const tokens = await getTokensFromCookies(config);
22
+
23
+ if (!tokens) {
24
+ debugLog("resolveSession: no tokens found in cookies");
25
+ return null;
26
+ }
27
+
28
+ const { accessToken, refreshToken } = tokens;
29
+
30
+ // If the access token is invalid here, the middleware either could not refresh
31
+ // (e.g. refresh token also expired) or is not running on this route.
32
+ // Either way, treat it as no session — do not attempt to set cookies.
33
+ if (!isTokenValid(accessToken)) {
34
+ debugLog(
35
+ "resolveSession: access token is invalid or expired — treating as no session",
36
+ );
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ const user = await config.adapter.fetchUser(accessToken);
42
+ debugLog("resolveSession: session resolved", { userId: user.id });
43
+ return { accessToken, refreshToken, user };
44
+ } catch (error) {
45
+ debugLog(
46
+ "resolveSession: adapter.fetchUser() threw — treating as no session",
47
+ {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ },
50
+ );
51
+ return null;
52
+ }
53
+ });
54
+
55
+ /**
56
+ * Returns the current session, or null if the user is not authenticated.
57
+ * Safe to call in any Server Component, layout, or server action.
58
+ * Results are deduplicated per request via React cache().
59
+ */
60
+ export async function getSession(): Promise<Session | null> {
61
+ return resolveSession();
62
+ }
63
+
64
+ /**
65
+ * Returns the current access token directly from cookies.
66
+ * Does NOT call fetchUser — use getSession() if you need the full session.
67
+ */
68
+ export async function getAccessToken(): Promise<string | null> {
69
+ const config = getGlobalAuthConfig();
70
+ const tokens = await getTokensFromCookies(config);
71
+ if (!tokens || !isTokenValid(tokens.accessToken)) return null;
72
+ return tokens.accessToken;
73
+ }
74
+
75
+ /**
76
+ * Returns the current refresh token directly from cookies.
77
+ * Does NOT call fetchUser — use getSession() if you need the full session.
78
+ */
79
+ export async function getRefreshToken(): Promise<string | null> {
80
+ const config = getGlobalAuthConfig();
81
+ const tokens = await getTokensFromCookies(config);
82
+ return tokens?.refreshToken ?? null;
83
+ }
84
+
85
+ /**
86
+ * Returns the current user, or null if not authenticated.
87
+ */
88
+ export async function getUser(): Promise<SessionUser | null> {
89
+ const session = await resolveSession();
90
+ return session?.user ?? null;
91
+ }
92
+
93
+ /**
94
+ * Returns the current session, or redirects to the sign-in page if not authenticated.
95
+ * Use this as a server-side guard in protected pages and layouts.
96
+ *
97
+ * When `includeCallbackUrl` is true (default), the current path is appended
98
+ * as a `?callbackUrl=` search param so your login page can redirect back
99
+ * after a successful login.
100
+ *
101
+ * @example
102
+ * // app/dashboard/page.tsx
103
+ * const session = await auth.requireSession();
104
+ * // session is guaranteed non-null here
105
+ */
106
+ export async function requireSession(
107
+ options: { includeCallbackUrl?: boolean } = {},
108
+ ): Promise<Session> {
109
+ const { includeCallbackUrl = true } = options;
110
+ const config = getGlobalAuthConfig();
111
+ const session = await resolveSession();
112
+
113
+ if (!session) {
114
+ if (includeCallbackUrl) {
115
+ try {
116
+ const headersList = await headers();
117
+ const currentPath =
118
+ headersList.get("x-pathname") ??
119
+ headersList.get("x-invoke-path") ??
120
+ "";
121
+ if (currentPath) {
122
+ debugLog(
123
+ "requireSession: unauthenticated — redirecting with callbackUrl",
124
+ {
125
+ signIn: config.pages.signIn,
126
+ callbackUrl: currentPath,
127
+ },
128
+ );
129
+ redirect(
130
+ `${config.pages.signIn}?callbackUrl=${encodeURIComponent(currentPath)}`,
131
+ );
132
+ }
133
+ } catch (error) {
134
+ if (isRedirectError(error)) throw error;
135
+ }
136
+ }
137
+
138
+ debugLog("requireSession: unauthenticated — redirecting to signIn", {
139
+ signIn: config.pages.signIn,
140
+ });
141
+ redirect(config.pages.signIn);
142
+ }
143
+
144
+ return session;
145
+ }
146
+
147
+ /** Checks if an error is the special NEXT_REDIRECT internal error. */
148
+ function isRedirectError(error: unknown): boolean {
149
+ return (
150
+ typeof error === "object" &&
151
+ error !== null &&
152
+ "digest" in error &&
153
+ typeof (error as Record<string, unknown>).digest === "string" &&
154
+ ((error as Record<string, unknown>).digest as string).startsWith(
155
+ "NEXT_REDIRECT",
156
+ )
157
+ );
158
+ }