@thunderid/nextjs 1.0.4 → 1.0.5
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/ThunderIDNextClient.d.ts +2 -1
- package/dist/ThunderIDNextClient.d.ts.map +1 -1
- package/dist/ThunderIDNextClient.js +16 -1
- package/dist/ThunderIDNextClient.js.map +1 -1
- package/dist/cjs/ThunderIDNextClient.cjs +15 -0
- package/dist/cjs/ThunderIDNextClient.cjs.map +1 -1
- package/dist/cjs/client/components/presentation/UserProfile/UserProfile.cjs +2 -1
- package/dist/cjs/client/components/presentation/UserProfile/UserProfile.cjs.map +1 -1
- package/dist/cjs/client/contexts/ThunderID/ThunderIDProvider.cjs +9 -2
- package/dist/cjs/client/contexts/ThunderID/ThunderIDProvider.cjs.map +1 -1
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/server/ThunderIDProvider.cjs +7 -0
- package/dist/cjs/server/ThunderIDProvider.cjs.map +1 -1
- package/dist/cjs/server/actions/clearSession.cjs +2 -1
- package/dist/cjs/server/actions/clearSession.cjs.map +1 -1
- package/dist/cjs/server/actions/getAccessToken.cjs +2 -1
- package/dist/cjs/server/actions/getAccessToken.cjs.map +1 -1
- package/dist/cjs/server/actions/getFlowMetaAction.cjs +44 -0
- package/dist/cjs/server/actions/getFlowMetaAction.cjs.map +1 -0
- package/dist/cjs/server/actions/getSessionId.cjs +2 -1
- package/dist/cjs/server/actions/getSessionId.cjs.map +1 -1
- package/dist/cjs/server/actions/getSessionPayload.cjs +2 -1
- package/dist/cjs/server/actions/getSessionPayload.cjs.map +1 -1
- package/dist/cjs/server/actions/getUserSchemaAction.cjs +31 -0
- package/dist/cjs/server/actions/getUserSchemaAction.cjs.map +1 -0
- package/dist/cjs/server/actions/handleOAuthCallbackAction.cjs +2 -1
- package/dist/cjs/server/actions/handleOAuthCallbackAction.cjs.map +1 -1
- package/dist/cjs/server/actions/refreshToken.cjs +4 -3
- package/dist/cjs/server/actions/refreshToken.cjs.map +1 -1
- package/dist/cjs/server/actions/signInAction.cjs +3 -2
- package/dist/cjs/server/actions/signInAction.cjs.map +1 -1
- package/dist/cjs/server/actions/signOutAction.cjs +2 -1
- package/dist/cjs/server/actions/signOutAction.cjs.map +1 -1
- package/dist/cjs/server/index.cjs +2 -0
- package/dist/cjs/server/proxy/thunderIDProxy.cjs +54 -28
- package/dist/cjs/server/proxy/thunderIDProxy.cjs.map +1 -1
- package/dist/cjs/utils/chunkedCookie.cjs +50 -0
- package/dist/cjs/utils/chunkedCookie.cjs.map +1 -0
- package/dist/cjs/utils/sessionUtils.cjs +2 -1
- package/dist/cjs/utils/sessionUtils.cjs.map +1 -1
- package/dist/client/components/presentation/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/client/components/presentation/UserProfile/UserProfile.js +2 -1
- package/dist/client/components/presentation/UserProfile/UserProfile.js.map +1 -1
- package/dist/client/contexts/ThunderID/ThunderIDProvider.d.ts +13 -1
- package/dist/client/contexts/ThunderID/ThunderIDProvider.d.ts.map +1 -1
- package/dist/client/contexts/ThunderID/ThunderIDProvider.js +9 -2
- package/dist/client/contexts/ThunderID/ThunderIDProvider.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/server/ThunderIDProvider.d.ts.map +1 -1
- package/dist/server/ThunderIDProvider.js +7 -0
- package/dist/server/ThunderIDProvider.js.map +1 -1
- package/dist/server/actions/clearSession.d.ts.map +1 -1
- package/dist/server/actions/clearSession.js +2 -1
- package/dist/server/actions/clearSession.js.map +1 -1
- package/dist/server/actions/getAccessToken.d.ts.map +1 -1
- package/dist/server/actions/getAccessToken.js +2 -1
- package/dist/server/actions/getAccessToken.js.map +1 -1
- package/dist/server/actions/getFlowMetaAction.d.ts +17 -0
- package/dist/server/actions/getFlowMetaAction.d.ts.map +1 -0
- package/dist/server/actions/getFlowMetaAction.js +42 -0
- package/dist/server/actions/getFlowMetaAction.js.map +1 -0
- package/dist/server/actions/getSessionId.d.ts.map +1 -1
- package/dist/server/actions/getSessionId.js +2 -1
- package/dist/server/actions/getSessionId.js.map +1 -1
- package/dist/server/actions/getSessionPayload.d.ts.map +1 -1
- package/dist/server/actions/getSessionPayload.js +2 -1
- package/dist/server/actions/getSessionPayload.js.map +1 -1
- package/dist/server/actions/getUserSchemaAction.d.ts +15 -0
- package/dist/server/actions/getUserSchemaAction.d.ts.map +1 -0
- package/dist/server/actions/getUserSchemaAction.js +31 -0
- package/dist/server/actions/getUserSchemaAction.js.map +1 -0
- package/dist/server/actions/handleOAuthCallbackAction.d.ts.map +1 -1
- package/dist/server/actions/handleOAuthCallbackAction.js +2 -1
- package/dist/server/actions/handleOAuthCallbackAction.js.map +1 -1
- package/dist/server/actions/refreshToken.d.ts.map +1 -1
- package/dist/server/actions/refreshToken.js +4 -3
- package/dist/server/actions/refreshToken.js.map +1 -1
- package/dist/server/actions/signInAction.d.ts.map +1 -1
- package/dist/server/actions/signInAction.js +3 -2
- package/dist/server/actions/signInAction.js.map +1 -1
- package/dist/server/actions/signOutAction.d.ts.map +1 -1
- package/dist/server/actions/signOutAction.js +2 -1
- package/dist/server/actions/signOutAction.js.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/proxy/thunderIDProxy.d.ts +13 -0
- package/dist/server/proxy/thunderIDProxy.d.ts.map +1 -1
- package/dist/server/proxy/thunderIDProxy.js +52 -29
- package/dist/server/proxy/thunderIDProxy.js.map +1 -1
- package/dist/utils/chunkedCookie.d.ts +46 -0
- package/dist/utils/chunkedCookie.d.ts.map +1 -0
- package/dist/utils/chunkedCookie.js +46 -0
- package/dist/utils/chunkedCookie.js.map +1 -0
- package/dist/utils/sessionUtils.d.ts.map +1 -1
- package/dist/utils/sessionUtils.js +2 -1
- package/dist/utils/sessionUtils.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,37 +1,60 @@
|
|
|
1
|
+
import { deleteChunkedCookie, getChunkedCookie, setChunkedCookie } from "../../utils/chunkedCookie.js";
|
|
1
2
|
import { REFRESH_BUFFER_SECONDS } from "../../constants/sessionConstants.js";
|
|
2
3
|
import SessionManager_default from "../../utils/SessionManager.js";
|
|
3
4
|
import decorateConfigWithNextEnv_default from "../../utils/decorateConfigWithNextEnv.js";
|
|
4
5
|
import handleRefreshToken_default from "../../utils/handleRefreshToken.js";
|
|
5
6
|
import { getSessionFromRequest, getSessionIdFromRequest } from "../../utils/sessionUtils.js";
|
|
7
|
+
import { CookieChunking } from "@thunderid/node";
|
|
6
8
|
import { NextResponse } from "next/server";
|
|
7
9
|
|
|
8
10
|
//#region src/server/proxy/thunderIDProxy.ts
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
12
|
+
* Splits a raw Cookie header string into its `{name, part}` entries, where
|
|
13
|
+
* `part` is the original `name=value` text (trimmed, never empty).
|
|
11
14
|
*/
|
|
12
|
-
const
|
|
13
|
-
const eqIdx =
|
|
14
|
-
return
|
|
15
|
-
|
|
15
|
+
const parseCookieHeaderParts = (cookieHeader) => cookieHeader.split(";").map((p) => p.trim()).filter(Boolean).map((part) => {
|
|
16
|
+
const eqIdx = part.indexOf("=");
|
|
17
|
+
return {
|
|
18
|
+
name: eqIdx === -1 ? part : part.slice(0, eqIdx).trim(),
|
|
19
|
+
part
|
|
20
|
+
};
|
|
21
|
+
});
|
|
16
22
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
23
|
+
* Removes a cookie from a raw Cookie header string — the unchunked `name`
|
|
24
|
+
* entry and/or any numbered `${name}.0`, `${name}.1`, ... chunk entries.
|
|
19
25
|
*/
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const removeChunkedCookieFromHeader = (cookieHeader, name) => {
|
|
27
|
+
const entries = parseCookieHeaderParts(cookieHeader);
|
|
28
|
+
const toRemove = new Set(CookieChunking.filterChunkNames(name, entries.map((e) => e.name)));
|
|
29
|
+
return entries.filter((e) => !toRemove.has(e.name)).map((e) => e.part).join("; ");
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Replaces a cookie's value inside a raw Cookie header string, splitting it
|
|
33
|
+
* across numbered `${name}.0`, `${name}.1`, ... chunk entries once it would
|
|
34
|
+
* exceed the ~4KB per-cookie limit browsers enforce. Any entry the cookie
|
|
35
|
+
* doesn't appear in yet is appended; stale chunk entries a shrunk value no
|
|
36
|
+
* longer needs are dropped.
|
|
37
|
+
*/
|
|
38
|
+
const replaceChunkedCookieInHeader = (cookieHeader, name, value) => {
|
|
39
|
+
const remaining = parseCookieHeaderParts(removeChunkedCookieFromHeader(cookieHeader, name)).map((e) => e.part);
|
|
40
|
+
const newParts = Object.entries(CookieChunking.split(name, value)).map(([chunkName, chunkValue]) => `${chunkName}=${chunkValue}`);
|
|
41
|
+
return [...remaining, ...newParts].join("; ");
|
|
33
42
|
};
|
|
34
43
|
/**
|
|
44
|
+
* Adapts a request/response pair into a {@link ChunkedCookieWriter}: existing
|
|
45
|
+
* chunk names are discovered from the incoming request's cookies (what the
|
|
46
|
+
* browser currently holds), while writes/deletes apply to the response.
|
|
47
|
+
*/
|
|
48
|
+
const toChunkedCookieWriter = (request, responseCookies) => ({
|
|
49
|
+
delete: (name) => {
|
|
50
|
+
responseCookies.delete(name);
|
|
51
|
+
},
|
|
52
|
+
getAll: () => request.cookies.getAll(),
|
|
53
|
+
set: (name, value, options) => {
|
|
54
|
+
responseCookies.set(name, value, options);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
35
58
|
* ThunderID proxy that integrates authentication into your Next.js application.
|
|
36
59
|
* Similar to Clerk's clerkMiddleware pattern.
|
|
37
60
|
*
|
|
@@ -96,7 +119,7 @@ const thunderIDProxy = (handler, options) => async (request) => {
|
|
|
96
119
|
const verifiedSession = await getSessionFromRequest(request);
|
|
97
120
|
let expiredSession;
|
|
98
121
|
if (!verifiedSession) {
|
|
99
|
-
const rawToken = request.cookies
|
|
122
|
+
const rawToken = getChunkedCookie(request.cookies, SessionManager_default.getSessionCookieName());
|
|
100
123
|
if (rawToken) try {
|
|
101
124
|
const decoded = await SessionManager_default.verifySessionTokenForRefresh(rawToken);
|
|
102
125
|
if (decoded.refreshToken) expiredSession = decoded;
|
|
@@ -123,7 +146,7 @@ const thunderIDProxy = (handler, options) => async (request) => {
|
|
|
123
146
|
} catch {
|
|
124
147
|
activeSession = void 0;
|
|
125
148
|
}
|
|
126
|
-
const rawSessionCookie = request.cookies
|
|
149
|
+
const rawSessionCookie = getChunkedCookie(request.cookies, SessionManager_default.getSessionCookieName());
|
|
127
150
|
let shouldClearCookie = false;
|
|
128
151
|
if (!isValidOAuthCallback && rawSessionCookie && !activeSession && !refreshCookieUpdate) shouldClearCookie = true;
|
|
129
152
|
const sessionId = activeSession?.sessionId ?? await getSessionIdFromRequest(request);
|
|
@@ -150,31 +173,31 @@ const thunderIDProxy = (handler, options) => async (request) => {
|
|
|
150
173
|
if (shouldClearCookie) {
|
|
151
174
|
const cookieName$1 = SessionManager_default.getSessionCookieName();
|
|
152
175
|
if (handlerResponse) {
|
|
153
|
-
handlerResponse.cookies
|
|
176
|
+
deleteChunkedCookie(toChunkedCookieWriter(request, handlerResponse.cookies), cookieName$1);
|
|
154
177
|
return handlerResponse;
|
|
155
178
|
}
|
|
156
179
|
const requestHeaders$1 = new Headers(request.headers);
|
|
157
|
-
requestHeaders$1.set("cookie",
|
|
180
|
+
requestHeaders$1.set("cookie", removeChunkedCookieFromHeader(request.headers.get("cookie") ?? "", cookieName$1));
|
|
158
181
|
const cleanResponse = NextResponse.next({ request: { headers: requestHeaders$1 } });
|
|
159
|
-
cleanResponse.cookies
|
|
182
|
+
deleteChunkedCookie(toChunkedCookieWriter(request, cleanResponse.cookies), cookieName$1);
|
|
160
183
|
return cleanResponse;
|
|
161
184
|
}
|
|
162
185
|
if (!refreshCookieUpdate) return handlerResponse ?? NextResponse.next();
|
|
163
186
|
const cookieName = SessionManager_default.getSessionCookieName();
|
|
164
187
|
const cookieOptions = SessionManager_default.getSessionCookieOptions(refreshCookieUpdate.expiry);
|
|
165
188
|
if (handlerResponse) {
|
|
166
|
-
handlerResponse.cookies
|
|
189
|
+
setChunkedCookie(toChunkedCookieWriter(request, handlerResponse.cookies), cookieName, refreshCookieUpdate.token, cookieOptions);
|
|
167
190
|
return handlerResponse;
|
|
168
191
|
}
|
|
169
192
|
const requestHeaders = new Headers(request.headers);
|
|
170
|
-
const updatedCookieHeader =
|
|
193
|
+
const updatedCookieHeader = replaceChunkedCookieInHeader(request.headers.get("cookie") ?? "", cookieName, refreshCookieUpdate.token);
|
|
171
194
|
requestHeaders.set("cookie", updatedCookieHeader);
|
|
172
195
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
173
|
-
response.cookies
|
|
196
|
+
setChunkedCookie(toChunkedCookieWriter(request, response.cookies), cookieName, refreshCookieUpdate.token, cookieOptions);
|
|
174
197
|
return response;
|
|
175
198
|
};
|
|
176
199
|
var thunderIDProxy_default = thunderIDProxy;
|
|
177
200
|
|
|
178
201
|
//#endregion
|
|
179
|
-
export { thunderIDProxy_default as default };
|
|
202
|
+
export { thunderIDProxy_default as default, removeChunkedCookieFromHeader, replaceChunkedCookieInHeader };
|
|
180
203
|
//# sourceMappingURL=thunderIDProxy.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"thunderIDProxy.js","names":["eqIdx: number","parts: string[]","updated: string[]","resolvedConfig: ThunderIDNextConfig","decorateConfigWithNextEnv","url: URL","hasCallbackParams: boolean","tempSessionToken: string | undefined","SessionManager","verifiedSession: SessionTokenPayload | undefined","expiredSession: SessionTokenPayload | undefined","rawToken: string | undefined","decoded: SessionTokenPayload","now: number","candidateSession: SessionTokenPayload | undefined","needsRefresh: boolean","activeSession: SessionTokenPayload | undefined","refreshCookieUpdate: {expiry: number; token: string} | undefined","handleRefreshToken","rawSessionCookie: string | undefined","sessionId: string | undefined","handlerResponse: NextResponse | void","referer: string | null","refererUrl: URL","requestUrl: URL","redirectUrl: string","cookieName: string","cookieName","requestHeaders: Headers","cleanResponse: NextResponse","requestHeaders","cookieOptions: ReturnType<typeof SessionManager.getSessionCookieOptions>","updatedCookieHeader: string","response: NextResponse"],"sources":["../../../src/server/proxy/thunderIDProxy.ts"],"sourcesContent":["// Copyright 2025 The ThunderID Authors\n// SPDX-License-Identifier: Apache-2.0\n\nimport {NextRequest, NextResponse} from 'next/server';\nimport {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants';\nimport {ThunderIDNextConfig} from '../../models/config';\nimport decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv';\nimport handleRefreshToken from '../../utils/handleRefreshToken';\nimport SessionManager, {SessionTokenPayload} from '../../utils/SessionManager';\nimport {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils';\n\nexport type ThunderIDProxyOptions = Partial<ThunderIDNextConfig>;\n\nexport interface ThunderIDProxyContext {\n /** Get the session payload from JWT session if available */\n getSession: () => Promise<SessionTokenPayload | undefined>;\n /** Get the session ID from the current request */\n getSessionId: () => string | undefined;\n /** Check if the current request has a valid ThunderID session */\n isSignedIn: () => boolean;\n /**\n * Protect a route by redirecting unauthenticated users.\n * Redirect URL fallback order:\n * 1. options.redirect\n * 2. resolvedOptions.signInUrl\n * 3. resolvedOptions.defaultRedirect\n * 4. referer (if from same origin)\n * If none are available, falls back to '/'.\n */\n protectRoute: (routeOptions?: {redirect?: string}) => Promise<NextResponse | void>;\n}\n\ntype ThunderIDProxyHandler = (\n thunderid: ThunderIDProxyContext,\n req: NextRequest,\n) => Promise<NextResponse | void> | NextResponse | void;\n\n/**\n * Removes a named cookie from a raw Cookie header string.\n */\nconst removeCookieFromHeader = (cookieHeader: string, name: string): string =>\n cookieHeader\n .split(';')\n .map((p: string) => p.trim())\n .filter((p: string) => {\n const eqIdx: number = p.indexOf('=');\n const partName: string = eqIdx === -1 ? p : p.slice(0, eqIdx).trim();\n return partName !== name;\n })\n .join('; ');\n\n/**\n * Replaces the value of a named cookie inside a raw Cookie header string.\n * If the cookie does not already appear in the header it is appended.\n */\nconst replaceCookieInHeader = (cookieHeader: string, name: string, value: string): string => {\n const parts: string[] = cookieHeader\n .split(';')\n .map((p: string) => p.trim())\n .filter(Boolean);\n\n let found = false;\n const updated: string[] = parts.map((part: string) => {\n const eqIdx: number = part.indexOf('=');\n const partName: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim();\n if (partName === name) {\n found = true;\n return `${name}=${value}`;\n }\n return part;\n });\n\n if (!found) {\n updated.push(`${name}=${value}`);\n }\n\n return updated.join('; ');\n};\n\n/**\n * ThunderID proxy that integrates authentication into your Next.js application.\n * Similar to Clerk's clerkMiddleware pattern.\n *\n * Proactively refreshes the access token when it is within REFRESH_BUFFER_SECONDS of\n * expiry so that Server Components always receive a fresh session. The refresh also\n * recovers expired tokens as long as a refresh token is present.\n *\n * The updated session cookie is written to:\n * - The response → browser stores the new cookie for subsequent requests.\n * - The forwarded request headers → the same-request Server Component render sees\n * the fresh token immediately without waiting for the next navigation.\n *\n * Token refresh requires baseUrl, clientId, and clientSecret. These are resolved from\n * the options argument first, then from the standard ThunderID environment variables\n * (NEXT_PUBLIC_THUNDERID_BASE_URL, NEXT_PUBLIC_THUNDERID_CLIENT_ID,\n * THUNDERID_CLIENT_SECRET). If none are available the refresh step is skipped silently.\n *\n * @param handler - Optional handler function to customize proxy behavior\n * @param options - Configuration options for the proxy\n * @returns Next.js middleware function\n *\n * @example\n * ```typescript\n * // middleware.ts - Basic usage (config read from env vars automatically)\n * import { thunderIDProxy } from '@thunderid/nextjs/server';\n *\n * export default thunderIDProxy();\n *\n * export const config = {\n * matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],\n * };\n * ```\n *\n * @example\n * ```typescript\n * // With route protection\n * import { thunderIDProxy, createRouteMatcher } from '@thunderid/nextjs/server';\n *\n * const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);\n *\n * export default thunderIDProxy(async (thunderid, req) => {\n * if (isProtectedRoute(req)) {\n * await thunderid.protectRoute();\n * }\n * });\n * ```\n */\nconst thunderIDProxy =\n (\n handler?: ThunderIDProxyHandler,\n options?: ThunderIDProxyOptions | ((req: NextRequest) => ThunderIDProxyOptions),\n ): ((request: NextRequest) => Promise<NextResponse>) =>\n async (request: NextRequest): Promise<NextResponse> => {\n const resolvedOptions: ThunderIDProxyOptions = typeof options === 'function' ? options(request) : options || {};\n\n // Resolve full config from passed options + environment variable fallbacks.\n const resolvedConfig: ThunderIDNextConfig = decorateConfigWithNextEnv(resolvedOptions as ThunderIDNextConfig);\n\n // ── OAuth callback detection ──────────────────────────────────────────────\n const url: URL = new URL(request.url);\n const hasCallbackParams: boolean = url.searchParams.has('code') && url.searchParams.has('state');\n\n let isValidOAuthCallback = false;\n if (hasCallbackParams && !url.searchParams.has('error')) {\n const tempSessionToken: string | undefined = request.cookies.get(\n SessionManager.getTempSessionCookieName(),\n )?.value;\n if (tempSessionToken) {\n try {\n await SessionManager.verifyTempSession(tempSessionToken);\n isValidOAuthCallback = true;\n } catch {\n isValidOAuthCallback = false;\n }\n }\n }\n\n // ── Session resolution ────────────────────────────────────────────────────\n // Step 1: Attempt to get a fully verified (signature + expiry) session.\n const verifiedSession: SessionTokenPayload | undefined = await getSessionFromRequest(request);\n\n // Step 2: If no verified session exists, verify the raw cookie's signature\n // without enforcing expiry. This allows the proxy to recover from an\n // expired access token as long as the JWT is authentic and a refresh token\n // is present. Skipping the signature check here would let a tampered cookie\n // drive identity-confusion attacks since handleRefreshToken reuses `sub`,\n // `sessionId`, and `organizationId` from the input payload when minting the\n // new session JWT.\n let expiredSession: SessionTokenPayload | undefined;\n if (!verifiedSession) {\n const rawToken: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value;\n if (rawToken) {\n try {\n const decoded: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(rawToken);\n if (decoded.refreshToken) {\n expiredSession = decoded;\n }\n } catch {\n // Forged, tampered, wrong type, or malformed — ignore.\n }\n }\n }\n\n // ── Token refresh ─────────────────────────────────────────────────────────\n const now: number = Math.floor(Date.now() / 1000);\n const candidateSession: SessionTokenPayload | undefined = verifiedSession ?? expiredSession;\n\n // Config is required to call the token endpoint.\n const hasRefreshConfig = !!(resolvedConfig.baseUrl && resolvedConfig.clientId && resolvedConfig.clientSecret);\n\n // Refresh when:\n // a) Token is verified but within the proactive buffer window, OR\n // b) Token has already expired but a refresh token is available.\n const needsRefresh: boolean =\n !isValidOAuthCallback &&\n hasRefreshConfig &&\n !!candidateSession?.refreshToken &&\n ((!!verifiedSession && verifiedSession.exp <= now + REFRESH_BUFFER_SECONDS) || !!expiredSession);\n\n let activeSession: SessionTokenPayload | undefined = verifiedSession;\n let refreshCookieUpdate: {expiry: number; token: string} | undefined;\n\n if (needsRefresh && candidateSession) {\n try {\n const {newSessionToken, sessionCookieExpiryTime} = await handleRefreshToken(candidateSession, {\n baseUrl: resolvedConfig.baseUrl!,\n clientId: resolvedConfig.clientId!,\n clientSecret: resolvedConfig.clientSecret!,\n sessionCookie: resolvedConfig.sessionCookie,\n });\n // Verify the newly minted token so activeSession reflects fresh claims.\n activeSession = await SessionManager.verifySessionToken(newSessionToken);\n refreshCookieUpdate = {expiry: sessionCookieExpiryTime, token: newSessionToken};\n } catch {\n // Refresh failed — clear the irrecoverable session.\n activeSession = undefined;\n }\n }\n\n // ── Session cleanup detection ─────────────────────────────────────────────\n // Mark stale cookies for deletion when the session is irrecoverable. Skipped\n // during OAuth callbacks where a session cookie may not exist yet.\n const rawSessionCookie: string | undefined = request.cookies.get(SessionManager.getSessionCookieName())?.value;\n\n let shouldClearCookie = false;\n\n if (!isValidOAuthCallback && rawSessionCookie && !activeSession && !refreshCookieUpdate) {\n // A cookie was present but all resolution paths (verify, decode, refresh)\n // failed — the session is dead and cannot be recovered.\n shouldClearCookie = true;\n }\n\n const sessionId: string | undefined = activeSession?.sessionId ?? (await getSessionIdFromRequest(request));\n const isAuthenticated = !!activeSession;\n\n // ── Proxy context ─────────────────────────────────────────────────────────\n const thunderid: ThunderIDProxyContext = {\n getSession: async (): Promise<SessionTokenPayload | undefined> => activeSession,\n getSessionId: (): string | undefined => sessionId,\n isSignedIn: (): boolean => isAuthenticated,\n protectRoute: async (routeOptions?: {redirect?: string}): Promise<NextResponse | void> => {\n // Skip during a valid OAuth callback to avoid redirecting before the\n // callback action has had a chance to complete.\n if (isValidOAuthCallback) {\n return undefined;\n }\n\n if (!isAuthenticated) {\n const referer: string | null = request.headers.get('referer');\n let fallbackRedirect = '/';\n\n if (referer) {\n try {\n const refererUrl: URL = new URL(referer);\n const requestUrl: URL = new URL(request.url);\n if (refererUrl.origin === requestUrl.origin) {\n fallbackRedirect = refererUrl.pathname + refererUrl.search;\n }\n } catch {\n // Invalid referer — ignore.\n }\n }\n\n const redirectUrl: string = routeOptions?.redirect ?? resolvedConfig.signInUrl! ?? fallbackRedirect;\n\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n\n return undefined;\n },\n };\n\n // ── Handler ───────────────────────────────────────────────────────────────\n const handlerResponse: NextResponse | void = handler ? await handler(thunderid, request) : undefined;\n\n // ── Build final response ──────────────────────────────────────────────────\n if (shouldClearCookie) {\n const cookieName: string = SessionManager.getSessionCookieName();\n\n if (handlerResponse) {\n // Handler returned a response (e.g. a redirect from protectRoute).\n // Attach the deletion so the browser discards the stale cookie.\n handlerResponse.cookies.delete(cookieName);\n return handlerResponse;\n }\n\n // Pass-through: strip the dead cookie from the forwarded request headers\n // so the same-request Server Component render sees no session at all.\n const requestHeaders: Headers = new Headers(request.headers);\n requestHeaders.set('cookie', removeCookieFromHeader(request.headers.get('cookie') ?? '', cookieName));\n const cleanResponse: NextResponse = NextResponse.next({request: {headers: requestHeaders}});\n cleanResponse.cookies.delete(cookieName);\n return cleanResponse;\n }\n\n if (!refreshCookieUpdate) {\n return handlerResponse ?? NextResponse.next();\n }\n\n // A token refresh occurred — the new session cookie must be applied to:\n // 1. The HTTP response so the browser stores the updated cookie.\n // 2. The forwarded request headers so the same-request Server Component\n // render reads the fresh session token instead of the expired one.\n const cookieName: string = SessionManager.getSessionCookieName();\n const cookieOptions: ReturnType<typeof SessionManager.getSessionCookieOptions> =\n SessionManager.getSessionCookieOptions(refreshCookieUpdate.expiry);\n\n if (handlerResponse) {\n // Handler returned a response (e.g. a redirect from protectRoute).\n // Attach the refresh cookie so the browser receives it even on redirects.\n handlerResponse.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions);\n return handlerResponse;\n }\n\n // Default pass-through: update both the response cookie and the request\n // Cookie header so the downstream Server Component render is not stale.\n const requestHeaders: Headers = new Headers(request.headers);\n const updatedCookieHeader: string = replaceCookieInHeader(\n request.headers.get('cookie') ?? '',\n cookieName,\n refreshCookieUpdate.token,\n );\n requestHeaders.set('cookie', updatedCookieHeader);\n\n const response: NextResponse = NextResponse.next({request: {headers: requestHeaders}});\n response.cookies.set(cookieName, refreshCookieUpdate.token, cookieOptions);\n return response;\n };\n\nexport default thunderIDProxy;\n"],"mappings":";;;;;;;;;;;AAwCA,MAAM,0BAA0B,cAAsB,SACpD,aACG,MAAM,IAAI,CACV,KAAK,MAAc,EAAE,MAAM,CAAC,CAC5B,QAAQ,MAAc;CACrB,MAAMA,QAAgB,EAAE,QAAQ,IAAI;AAEpC,SADyB,UAAU,KAAK,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,MAChD;EACpB,CACD,KAAK,KAAK;;;;;AAMf,MAAM,yBAAyB,cAAsB,MAAc,UAA0B;CAC3F,MAAMC,QAAkB,aACrB,MAAM,IAAI,CACV,KAAK,MAAc,EAAE,MAAM,CAAC,CAC5B,OAAO,QAAQ;CAElB,IAAI,QAAQ;CACZ,MAAMC,UAAoB,MAAM,KAAK,SAAiB;EACpD,MAAMF,QAAgB,KAAK,QAAQ,IAAI;AAEvC,OADyB,UAAU,KAAK,OAAO,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,MACzD,MAAM;AACrB,WAAQ;AACR,UAAO,GAAG,KAAK,GAAG;;AAEpB,SAAO;GACP;AAEF,KAAI,CAAC,MACH,SAAQ,KAAK,GAAG,KAAK,GAAG,QAAQ;AAGlC,QAAO,QAAQ,KAAK,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmD3B,MAAM,kBAEF,SACA,YAEF,OAAO,YAAgD;CAIrD,MAAMG,iBAAsCC,kCAHG,OAAO,YAAY,aAAa,QAAQ,QAAQ,GAAG,WAAW,EAAE,CAGF;CAG7G,MAAMC,MAAW,IAAI,IAAI,QAAQ,IAAI;CACrC,MAAMC,oBAA6B,IAAI,aAAa,IAAI,OAAO,IAAI,IAAI,aAAa,IAAI,QAAQ;CAEhG,IAAI,uBAAuB;AAC3B,KAAI,qBAAqB,CAAC,IAAI,aAAa,IAAI,QAAQ,EAAE;EACvD,MAAMC,mBAAuC,QAAQ,QAAQ,IAC3DC,uBAAe,0BAA0B,CAC1C,EAAE;AACH,MAAI,iBACF,KAAI;AACF,SAAMA,uBAAe,kBAAkB,iBAAiB;AACxD,0BAAuB;UACjB;AACN,0BAAuB;;;CAO7B,MAAMC,kBAAmD,MAAM,sBAAsB,QAAQ;CAS7F,IAAIC;AACJ,KAAI,CAAC,iBAAiB;EACpB,MAAMC,WAA+B,QAAQ,QAAQ,IAAIH,uBAAe,sBAAsB,CAAC,EAAE;AACjG,MAAI,SACF,KAAI;GACF,MAAMI,UAA+B,MAAMJ,uBAAe,6BAA6B,SAAS;AAChG,OAAI,QAAQ,aACV,kBAAiB;UAEb;;CAOZ,MAAMK,MAAc,KAAK,MAAM,KAAK,KAAK,GAAG,IAAK;CACjD,MAAMC,mBAAoD,mBAAmB;CAG7E,MAAM,mBAAmB,CAAC,EAAE,eAAe,WAAW,eAAe,YAAY,eAAe;CAKhG,MAAMC,eACJ,CAAC,wBACD,oBACA,CAAC,CAAC,kBAAkB,iBAClB,CAAC,CAAC,mBAAmB,gBAAgB,OAAO,MAAM,0BAA2B,CAAC,CAAC;CAEnF,IAAIC,gBAAiD;CACrD,IAAIC;AAEJ,KAAI,gBAAgB,iBAClB,KAAI;EACF,MAAM,EAAC,iBAAiB,4BAA2B,MAAMC,2BAAmB,kBAAkB;GAC5F,SAAS,eAAe;GACxB,UAAU,eAAe;GACzB,cAAc,eAAe;GAC7B,eAAe,eAAe;GAC/B,CAAC;AAEF,kBAAgB,MAAMV,uBAAe,mBAAmB,gBAAgB;AACxE,wBAAsB;GAAC,QAAQ;GAAyB,OAAO;GAAgB;SACzE;AAEN,kBAAgB;;CAOpB,MAAMW,mBAAuC,QAAQ,QAAQ,IAAIX,uBAAe,sBAAsB,CAAC,EAAE;CAEzG,IAAI,oBAAoB;AAExB,KAAI,CAAC,wBAAwB,oBAAoB,CAAC,iBAAiB,CAAC,oBAGlE,qBAAoB;CAGtB,MAAMY,YAAgC,eAAe,aAAc,MAAM,wBAAwB,QAAQ;CACzG,MAAM,kBAAkB,CAAC,CAAC;CAwC1B,MAAMC,kBAAuC,UAAU,MAAM,QArCpB;EACvC,YAAY,YAAsD;EAClE,oBAAwC;EACxC,kBAA2B;EAC3B,cAAc,OAAO,iBAAqE;AAGxF,OAAI,qBACF;AAGF,OAAI,CAAC,iBAAiB;IACpB,MAAMC,UAAyB,QAAQ,QAAQ,IAAI,UAAU;IAC7D,IAAI,mBAAmB;AAEvB,QAAI,QACF,KAAI;KACF,MAAMC,aAAkB,IAAI,IAAI,QAAQ;KACxC,MAAMC,aAAkB,IAAI,IAAI,QAAQ,IAAI;AAC5C,SAAI,WAAW,WAAW,WAAW,OACnC,oBAAmB,WAAW,WAAW,WAAW;YAEhD;IAKV,MAAMC,cAAsB,cAAc,YAAY,eAAe,aAAc;AAEnF,WAAO,aAAa,SAAS,IAAI,IAAI,aAAa,QAAQ,IAAI,CAAC;;;EAKpE,EAG+E,QAAQ,GAAG;AAG3F,KAAI,mBAAmB;EACrB,MAAMC,eAAqBlB,uBAAe,sBAAsB;AAEhE,MAAI,iBAAiB;AAGnB,mBAAgB,QAAQ,OAAOmB,aAAW;AAC1C,UAAO;;EAKT,MAAMC,mBAA0B,IAAI,QAAQ,QAAQ,QAAQ;AAC5D,mBAAe,IAAI,UAAU,uBAAuB,QAAQ,QAAQ,IAAI,SAAS,IAAI,IAAID,aAAW,CAAC;EACrG,MAAME,gBAA8B,aAAa,KAAK,EAAC,SAAS,EAAC,SAASC,kBAAe,EAAC,CAAC;AAC3F,gBAAc,QAAQ,OAAOH,aAAW;AACxC,SAAO;;AAGT,KAAI,CAAC,oBACH,QAAO,mBAAmB,aAAa,MAAM;CAO/C,MAAMD,aAAqBlB,uBAAe,sBAAsB;CAChE,MAAMuB,gBACJvB,uBAAe,wBAAwB,oBAAoB,OAAO;AAEpE,KAAI,iBAAiB;AAGnB,kBAAgB,QAAQ,IAAI,YAAY,oBAAoB,OAAO,cAAc;AACjF,SAAO;;CAKT,MAAMoB,iBAA0B,IAAI,QAAQ,QAAQ,QAAQ;CAC5D,MAAMI,sBAA8B,sBAClC,QAAQ,QAAQ,IAAI,SAAS,IAAI,IACjC,YACA,oBAAoB,MACrB;AACD,gBAAe,IAAI,UAAU,oBAAoB;CAEjD,MAAMC,WAAyB,aAAa,KAAK,EAAC,SAAS,EAAC,SAAS,gBAAe,EAAC,CAAC;AACtF,UAAS,QAAQ,IAAI,YAAY,oBAAoB,OAAO,cAAc;AAC1E,QAAO;;AAGX,6BAAe"}
|
|
1
|
+
{"version":3,"file":"thunderIDProxy.js","names":["eqIdx: number","entries: {name: string; part: string}[]","remaining: string[]","newParts: string[]","resolvedConfig: ThunderIDNextConfig","decorateConfigWithNextEnv","url: URL","hasCallbackParams: boolean","tempSessionToken: string | undefined","SessionManager","verifiedSession: SessionTokenPayload | undefined","expiredSession: SessionTokenPayload | undefined","rawToken: string | undefined","decoded: SessionTokenPayload","now: number","candidateSession: SessionTokenPayload | undefined","needsRefresh: boolean","activeSession: SessionTokenPayload | undefined","refreshCookieUpdate: {expiry: number; token: string} | undefined","handleRefreshToken","rawSessionCookie: string | undefined","sessionId: string | undefined","handlerResponse: NextResponse | void","referer: string | null","refererUrl: URL","requestUrl: URL","redirectUrl: string","cookieName: string","cookieName","requestHeaders: Headers","cleanResponse: NextResponse","requestHeaders","cookieOptions: ReturnType<typeof SessionManager.getSessionCookieOptions>","updatedCookieHeader: string","response: NextResponse"],"sources":["../../../src/server/proxy/thunderIDProxy.ts"],"sourcesContent":["// Copyright 2025 The ThunderID Authors\n// SPDX-License-Identifier: Apache-2.0\n\nimport {CookieChunking} from '@thunderid/node';\nimport {NextRequest, NextResponse} from 'next/server';\nimport {REFRESH_BUFFER_SECONDS} from '../../constants/sessionConstants';\nimport {ThunderIDNextConfig} from '../../models/config';\nimport {\n ChunkedCookieOptions,\n ChunkedCookieWriter,\n deleteChunkedCookie,\n getChunkedCookie,\n setChunkedCookie,\n} from '../../utils/chunkedCookie';\nimport decorateConfigWithNextEnv from '../../utils/decorateConfigWithNextEnv';\nimport handleRefreshToken from '../../utils/handleRefreshToken';\nimport SessionManager, {SessionTokenPayload} from '../../utils/SessionManager';\nimport {getSessionFromRequest, getSessionIdFromRequest} from '../../utils/sessionUtils';\n\nexport type ThunderIDProxyOptions = Partial<ThunderIDNextConfig>;\n\nexport interface ThunderIDProxyContext {\n /** Get the session payload from JWT session if available */\n getSession: () => Promise<SessionTokenPayload | undefined>;\n /** Get the session ID from the current request */\n getSessionId: () => string | undefined;\n /** Check if the current request has a valid ThunderID session */\n isSignedIn: () => boolean;\n /**\n * Protect a route by redirecting unauthenticated users.\n * Redirect URL fallback order:\n * 1. options.redirect\n * 2. resolvedOptions.signInUrl\n * 3. resolvedOptions.defaultRedirect\n * 4. referer (if from same origin)\n * If none are available, falls back to '/'.\n */\n protectRoute: (routeOptions?: {redirect?: string}) => Promise<NextResponse | void>;\n}\n\ntype ThunderIDProxyHandler = (\n thunderid: ThunderIDProxyContext,\n req: NextRequest,\n) => Promise<NextResponse | void> | NextResponse | void;\n\n/**\n * Splits a raw Cookie header string into its `{name, part}` entries, where\n * `part` is the original `name=value` text (trimmed, never empty).\n */\nconst parseCookieHeaderParts = (cookieHeader: string): {name: string; part: string}[] =>\n cookieHeader\n .split(';')\n .map((p: string) => p.trim())\n .filter(Boolean)\n .map((part: string) => {\n const eqIdx: number = part.indexOf('=');\n const name: string = eqIdx === -1 ? part : part.slice(0, eqIdx).trim();\n return {name, part};\n });\n\n/**\n * Removes a cookie from a raw Cookie header string — the unchunked `name`\n * entry and/or any numbered `${name}.0`, `${name}.1`, ... chunk entries.\n */\nexport const removeChunkedCookieFromHeader = (cookieHeader: string, name: string): string => {\n const entries: {name: string; part: string}[] = parseCookieHeaderParts(cookieHeader);\n const toRemove = new Set<string>(\n CookieChunking.filterChunkNames(\n name,\n entries.map((e) => e.name),\n ),\n );\n\n return entries\n .filter((e) => !toRemove.has(e.name))\n .map((e) => e.part)\n .join('; ');\n};\n\n/**\n * Replaces a cookie's value inside a raw Cookie header string, splitting it\n * across numbered `${name}.0`, `${name}.1`, ... chunk entries once it would\n * exceed the ~4KB per-cookie limit browsers enforce. Any entry the cookie\n * doesn't appear in yet is appended; stale chunk entries a shrunk value no\n * longer needs are dropped.\n */\nexport const replaceChunkedCookieInHeader = (cookieHeader: string, name: string, value: string): string => {\n const remaining: string[] = parseCookieHeaderParts(removeChunkedCookieFromHeader(cookieHeader, name)).map(\n (e) => e.part,\n );\n const newParts: string[] = Object.entries(CookieChunking.split(name, value)).map(\n ([chunkName, chunkValue]: [string, string]) => `${chunkName}=${chunkValue}`,\n );\n\n return [...remaining, ...newParts].join('; ');\n};\n\n/**\n * Adapts a request/response pair into a {@link ChunkedCookieWriter}: existing\n * chunk names are discovered from the incoming request's cookies (what the\n * browser currently holds), while writes/deletes apply to the response.\n */\nconst toChunkedCookieWriter = (\n request: NextRequest,\n responseCookies: NextResponse['cookies'],\n): ChunkedCookieWriter => ({\n delete: (name: string): void => {\n responseCookies.delete(name);\n },\n getAll: (): {name: string}[] => request.cookies.getAll(),\n set: (name: string, value: string, options: ChunkedCookieOptions): void => {\n responseCookies.set(name, value, options);\n },\n});\n\n/**\n * ThunderID proxy that integrates authentication into your Next.js application.\n * Similar to Clerk's clerkMiddleware pattern.\n *\n * Proactively refreshes the access token when it is within REFRESH_BUFFER_SECONDS of\n * expiry so that Server Components always receive a fresh session. The refresh also\n * recovers expired tokens as long as a refresh token is present.\n *\n * The updated session cookie is written to:\n * - The response → browser stores the new cookie for subsequent requests.\n * - The forwarded request headers → the same-request Server Component render sees\n * the fresh token immediately without waiting for the next navigation.\n *\n * Token refresh requires baseUrl, clientId, and clientSecret. These are resolved from\n * the options argument first, then from the standard ThunderID environment variables\n * (NEXT_PUBLIC_THUNDERID_BASE_URL, NEXT_PUBLIC_THUNDERID_CLIENT_ID,\n * THUNDERID_CLIENT_SECRET). If none are available the refresh step is skipped silently.\n *\n * @param handler - Optional handler function to customize proxy behavior\n * @param options - Configuration options for the proxy\n * @returns Next.js middleware function\n *\n * @example\n * ```typescript\n * // middleware.ts - Basic usage (config read from env vars automatically)\n * import { thunderIDProxy } from '@thunderid/nextjs/server';\n *\n * export default thunderIDProxy();\n *\n * export const config = {\n * matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],\n * };\n * ```\n *\n * @example\n * ```typescript\n * // With route protection\n * import { thunderIDProxy, createRouteMatcher } from '@thunderid/nextjs/server';\n *\n * const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);\n *\n * export default thunderIDProxy(async (thunderid, req) => {\n * if (isProtectedRoute(req)) {\n * await thunderid.protectRoute();\n * }\n * });\n * ```\n */\nconst thunderIDProxy =\n (\n handler?: ThunderIDProxyHandler,\n options?: ThunderIDProxyOptions | ((req: NextRequest) => ThunderIDProxyOptions),\n ): ((request: NextRequest) => Promise<NextResponse>) =>\n async (request: NextRequest): Promise<NextResponse> => {\n const resolvedOptions: ThunderIDProxyOptions = typeof options === 'function' ? options(request) : options || {};\n\n // Resolve full config from passed options + environment variable fallbacks.\n const resolvedConfig: ThunderIDNextConfig = decorateConfigWithNextEnv(resolvedOptions as ThunderIDNextConfig);\n\n // ── OAuth callback detection ──────────────────────────────────────────────\n const url: URL = new URL(request.url);\n const hasCallbackParams: boolean = url.searchParams.has('code') && url.searchParams.has('state');\n\n let isValidOAuthCallback = false;\n if (hasCallbackParams && !url.searchParams.has('error')) {\n const tempSessionToken: string | undefined = request.cookies.get(\n SessionManager.getTempSessionCookieName(),\n )?.value;\n if (tempSessionToken) {\n try {\n await SessionManager.verifyTempSession(tempSessionToken);\n isValidOAuthCallback = true;\n } catch {\n isValidOAuthCallback = false;\n }\n }\n }\n\n // ── Session resolution ────────────────────────────────────────────────────\n // Step 1: Attempt to get a fully verified (signature + expiry) session.\n const verifiedSession: SessionTokenPayload | undefined = await getSessionFromRequest(request);\n\n // Step 2: If no verified session exists, verify the raw cookie's signature\n // without enforcing expiry. This allows the proxy to recover from an\n // expired access token as long as the JWT is authentic and a refresh token\n // is present. Skipping the signature check here would let a tampered cookie\n // drive identity-confusion attacks since handleRefreshToken reuses `sub`,\n // `sessionId`, and `organizationId` from the input payload when minting the\n // new session JWT.\n let expiredSession: SessionTokenPayload | undefined;\n if (!verifiedSession) {\n const rawToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName());\n if (rawToken) {\n try {\n const decoded: SessionTokenPayload = await SessionManager.verifySessionTokenForRefresh(rawToken);\n if (decoded.refreshToken) {\n expiredSession = decoded;\n }\n } catch {\n // Forged, tampered, wrong type, or malformed — ignore.\n }\n }\n }\n\n // ── Token refresh ─────────────────────────────────────────────────────────\n const now: number = Math.floor(Date.now() / 1000);\n const candidateSession: SessionTokenPayload | undefined = verifiedSession ?? expiredSession;\n\n // Config is required to call the token endpoint.\n const hasRefreshConfig = !!(resolvedConfig.baseUrl && resolvedConfig.clientId && resolvedConfig.clientSecret);\n\n // Refresh when:\n // a) Token is verified but within the proactive buffer window, OR\n // b) Token has already expired but a refresh token is available.\n const needsRefresh: boolean =\n !isValidOAuthCallback &&\n hasRefreshConfig &&\n !!candidateSession?.refreshToken &&\n ((!!verifiedSession && verifiedSession.exp <= now + REFRESH_BUFFER_SECONDS) || !!expiredSession);\n\n let activeSession: SessionTokenPayload | undefined = verifiedSession;\n let refreshCookieUpdate: {expiry: number; token: string} | undefined;\n\n if (needsRefresh && candidateSession) {\n try {\n const {newSessionToken, sessionCookieExpiryTime} = await handleRefreshToken(candidateSession, {\n baseUrl: resolvedConfig.baseUrl!,\n clientId: resolvedConfig.clientId!,\n clientSecret: resolvedConfig.clientSecret!,\n sessionCookie: resolvedConfig.sessionCookie,\n });\n // Verify the newly minted token so activeSession reflects fresh claims.\n activeSession = await SessionManager.verifySessionToken(newSessionToken);\n refreshCookieUpdate = {expiry: sessionCookieExpiryTime, token: newSessionToken};\n } catch {\n // Refresh failed — clear the irrecoverable session.\n activeSession = undefined;\n }\n }\n\n // ── Session cleanup detection ─────────────────────────────────────────────\n // Mark stale cookies for deletion when the session is irrecoverable. Skipped\n // during OAuth callbacks where a session cookie may not exist yet.\n const rawSessionCookie: string | undefined = getChunkedCookie(\n request.cookies,\n SessionManager.getSessionCookieName(),\n );\n\n let shouldClearCookie = false;\n\n if (!isValidOAuthCallback && rawSessionCookie && !activeSession && !refreshCookieUpdate) {\n // A cookie was present but all resolution paths (verify, decode, refresh)\n // failed — the session is dead and cannot be recovered.\n shouldClearCookie = true;\n }\n\n const sessionId: string | undefined = activeSession?.sessionId ?? (await getSessionIdFromRequest(request));\n const isAuthenticated = !!activeSession;\n\n // ── Proxy context ─────────────────────────────────────────────────────────\n const thunderid: ThunderIDProxyContext = {\n getSession: async (): Promise<SessionTokenPayload | undefined> => activeSession,\n getSessionId: (): string | undefined => sessionId,\n isSignedIn: (): boolean => isAuthenticated,\n protectRoute: async (routeOptions?: {redirect?: string}): Promise<NextResponse | void> => {\n // Skip during a valid OAuth callback to avoid redirecting before the\n // callback action has had a chance to complete.\n if (isValidOAuthCallback) {\n return undefined;\n }\n\n if (!isAuthenticated) {\n const referer: string | null = request.headers.get('referer');\n let fallbackRedirect = '/';\n\n if (referer) {\n try {\n const refererUrl: URL = new URL(referer);\n const requestUrl: URL = new URL(request.url);\n if (refererUrl.origin === requestUrl.origin) {\n fallbackRedirect = refererUrl.pathname + refererUrl.search;\n }\n } catch {\n // Invalid referer — ignore.\n }\n }\n\n const redirectUrl: string = routeOptions?.redirect ?? resolvedConfig.signInUrl! ?? fallbackRedirect;\n\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n\n return undefined;\n },\n };\n\n // ── Handler ───────────────────────────────────────────────────────────────\n const handlerResponse: NextResponse | void = handler ? await handler(thunderid, request) : undefined;\n\n // ── Build final response ──────────────────────────────────────────────────\n if (shouldClearCookie) {\n const cookieName: string = SessionManager.getSessionCookieName();\n\n if (handlerResponse) {\n // Handler returned a response (e.g. a redirect from protectRoute).\n // Attach the deletion so the browser discards the stale cookie (and\n // every chunk of it).\n deleteChunkedCookie(toChunkedCookieWriter(request, handlerResponse.cookies), cookieName);\n return handlerResponse;\n }\n\n // Pass-through: strip the dead cookie from the forwarded request headers\n // so the same-request Server Component render sees no session at all.\n const requestHeaders: Headers = new Headers(request.headers);\n requestHeaders.set('cookie', removeChunkedCookieFromHeader(request.headers.get('cookie') ?? '', cookieName));\n const cleanResponse: NextResponse = NextResponse.next({request: {headers: requestHeaders}});\n deleteChunkedCookie(toChunkedCookieWriter(request, cleanResponse.cookies), cookieName);\n return cleanResponse;\n }\n\n if (!refreshCookieUpdate) {\n return handlerResponse ?? NextResponse.next();\n }\n\n // A token refresh occurred — the new session cookie must be applied to:\n // 1. The HTTP response so the browser stores the updated cookie.\n // 2. The forwarded request headers so the same-request Server Component\n // render reads the fresh session token instead of the expired one.\n const cookieName: string = SessionManager.getSessionCookieName();\n const cookieOptions: ReturnType<typeof SessionManager.getSessionCookieOptions> =\n SessionManager.getSessionCookieOptions(refreshCookieUpdate.expiry);\n\n if (handlerResponse) {\n // Handler returned a response (e.g. a redirect from protectRoute).\n // Attach the refresh cookie so the browser receives it even on redirects.\n setChunkedCookie(\n toChunkedCookieWriter(request, handlerResponse.cookies),\n cookieName,\n refreshCookieUpdate.token,\n cookieOptions,\n );\n return handlerResponse;\n }\n\n // Default pass-through: update both the response cookie and the request\n // Cookie header so the downstream Server Component render is not stale.\n const requestHeaders: Headers = new Headers(request.headers);\n const updatedCookieHeader: string = replaceChunkedCookieInHeader(\n request.headers.get('cookie') ?? '',\n cookieName,\n refreshCookieUpdate.token,\n );\n requestHeaders.set('cookie', updatedCookieHeader);\n\n const response: NextResponse = NextResponse.next({request: {headers: requestHeaders}});\n setChunkedCookie(\n toChunkedCookieWriter(request, response.cookies),\n cookieName,\n refreshCookieUpdate.token,\n cookieOptions,\n );\n return response;\n };\n\nexport default thunderIDProxy;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ChunkedCookieOptions {
|
|
2
|
+
httpOnly: boolean;
|
|
3
|
+
maxAge: number;
|
|
4
|
+
path: string;
|
|
5
|
+
sameSite: 'lax';
|
|
6
|
+
secure: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Minimal shape shared by `cookies()` (next/headers) and `NextRequest.cookies`. */
|
|
9
|
+
export interface ChunkedCookieReader {
|
|
10
|
+
get(name: string): {
|
|
11
|
+
value: string;
|
|
12
|
+
} | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Minimal shape shared by `cookies()` (next/headers) and `NextResponse.cookies`. */
|
|
15
|
+
export interface ChunkedCookieWriter {
|
|
16
|
+
delete(name: string): void;
|
|
17
|
+
getAll(): {
|
|
18
|
+
name: string;
|
|
19
|
+
}[];
|
|
20
|
+
set(name: string, value: string, options: ChunkedCookieOptions): void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read a cookie that may have been split across `${name}.0`, `${name}.1`,
|
|
24
|
+
* ... chunks, reassembling it into the original value. Falls back to the
|
|
25
|
+
* unchunked `name` cookie when the value fit in a single cookie.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getChunkedCookie(store: ChunkedCookieReader, name: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Write a cookie value, splitting it across numbered `${name}.0`,
|
|
30
|
+
* `${name}.1`, ... chunks once it would exceed the ~4KB per-cookie limit
|
|
31
|
+
* browsers enforce, and reassembling transparently via {@link getChunkedCookie}.
|
|
32
|
+
*
|
|
33
|
+
* Clears any cookie names the previous value needed but the new one doesn't
|
|
34
|
+
* (e.g. a smaller re-issued session that now fits in fewer chunks, or in a
|
|
35
|
+
* single unchunked cookie). `store.getAll()` is used to discover those stale
|
|
36
|
+
* names, so pass the store that reflects the cookies already present (for
|
|
37
|
+
* `cookies()` that's the same store being written to; for middleware it
|
|
38
|
+
* should be an adapter backed by the incoming `NextRequest.cookies`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function setChunkedCookie(store: ChunkedCookieWriter, name: string, value: string, options: ChunkedCookieOptions): void;
|
|
41
|
+
/**
|
|
42
|
+
* Delete a cookie that may have been chunked — clears the base cookie name
|
|
43
|
+
* and every numbered chunk present in `store`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function deleteChunkedCookie(store: ChunkedCookieWriter, name: string): void;
|
|
46
|
+
//# sourceMappingURL=chunkedCookie.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunkedCookie.d.ts","sourceRoot":"","sources":["../../src/utils/chunkedCookie.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,KAAK,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,oFAAoF;AACpF,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,GAAG,SAAS,CAAC;CAChD;AAED,qFAAqF;AACrF,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,IAAI;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,EAAE,CAAC;IAC3B,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAAC;CACvE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,mBAAmB,EAC1B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CAiBN;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAclF"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CookieChunking } from "@thunderid/node";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/chunkedCookie.ts
|
|
4
|
+
/**
|
|
5
|
+
* Read a cookie that may have been split across `${name}.0`, `${name}.1`,
|
|
6
|
+
* ... chunks, reassembling it into the original value. Falls back to the
|
|
7
|
+
* unchunked `name` cookie when the value fit in a single cookie.
|
|
8
|
+
*/
|
|
9
|
+
function getChunkedCookie(store, name) {
|
|
10
|
+
return CookieChunking.join(name, (cookieName) => store.get(cookieName)?.value);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Write a cookie value, splitting it across numbered `${name}.0`,
|
|
14
|
+
* `${name}.1`, ... chunks once it would exceed the ~4KB per-cookie limit
|
|
15
|
+
* browsers enforce, and reassembling transparently via {@link getChunkedCookie}.
|
|
16
|
+
*
|
|
17
|
+
* Clears any cookie names the previous value needed but the new one doesn't
|
|
18
|
+
* (e.g. a smaller re-issued session that now fits in fewer chunks, or in a
|
|
19
|
+
* single unchunked cookie). `store.getAll()` is used to discover those stale
|
|
20
|
+
* names, so pass the store that reflects the cookies already present (for
|
|
21
|
+
* `cookies()` that's the same store being written to; for middleware it
|
|
22
|
+
* should be an adapter backed by the incoming `NextRequest.cookies`).
|
|
23
|
+
*/
|
|
24
|
+
function setChunkedCookie(store, name, value, options) {
|
|
25
|
+
const existing = CookieChunking.filterChunkNames(name, store.getAll().map((cookie) => cookie.name));
|
|
26
|
+
const newChunks = CookieChunking.split(name, value);
|
|
27
|
+
const newNames = new Set(Object.keys(newChunks));
|
|
28
|
+
for (const existingName of existing) if (!newNames.has(existingName)) store.delete(existingName);
|
|
29
|
+
for (const [chunkName, chunkValue] of Object.entries(newChunks)) store.set(chunkName, chunkValue, options);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Delete a cookie that may have been chunked — clears the base cookie name
|
|
33
|
+
* and every numbered chunk present in `store`.
|
|
34
|
+
*/
|
|
35
|
+
function deleteChunkedCookie(store, name) {
|
|
36
|
+
const existing = CookieChunking.filterChunkNames(name, store.getAll().map((cookie) => cookie.name));
|
|
37
|
+
if (existing.length === 0) {
|
|
38
|
+
store.delete(name);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const existingName of existing) store.delete(existingName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { deleteChunkedCookie, getChunkedCookie, setChunkedCookie };
|
|
46
|
+
//# sourceMappingURL=chunkedCookie.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunkedCookie.js","names":["existing: string[]","newChunks: Record<string, string>"],"sources":["../../src/utils/chunkedCookie.ts"],"sourcesContent":["// Copyright 2025 The ThunderID Authors\n// SPDX-License-Identifier: Apache-2.0\n\nimport {CookieChunking} from '@thunderid/node';\n\nexport interface ChunkedCookieOptions {\n httpOnly: boolean;\n maxAge: number;\n path: string;\n sameSite: 'lax';\n secure: boolean;\n}\n\n/** Minimal shape shared by `cookies()` (next/headers) and `NextRequest.cookies`. */\nexport interface ChunkedCookieReader {\n get(name: string): {value: string} | undefined;\n}\n\n/** Minimal shape shared by `cookies()` (next/headers) and `NextResponse.cookies`. */\nexport interface ChunkedCookieWriter {\n delete(name: string): void;\n getAll(): {name: string}[];\n set(name: string, value: string, options: ChunkedCookieOptions): void;\n}\n\n/**\n * Read a cookie that may have been split across `${name}.0`, `${name}.1`,\n * ... chunks, reassembling it into the original value. Falls back to the\n * unchunked `name` cookie when the value fit in a single cookie.\n */\nexport function getChunkedCookie(store: ChunkedCookieReader, name: string): string | undefined {\n return CookieChunking.join(name, (cookieName: string) => store.get(cookieName)?.value);\n}\n\n/**\n * Write a cookie value, splitting it across numbered `${name}.0`,\n * `${name}.1`, ... chunks once it would exceed the ~4KB per-cookie limit\n * browsers enforce, and reassembling transparently via {@link getChunkedCookie}.\n *\n * Clears any cookie names the previous value needed but the new one doesn't\n * (e.g. a smaller re-issued session that now fits in fewer chunks, or in a\n * single unchunked cookie). `store.getAll()` is used to discover those stale\n * names, so pass the store that reflects the cookies already present (for\n * `cookies()` that's the same store being written to; for middleware it\n * should be an adapter backed by the incoming `NextRequest.cookies`).\n */\nexport function setChunkedCookie(\n store: ChunkedCookieWriter,\n name: string,\n value: string,\n options: ChunkedCookieOptions,\n): void {\n const existing: string[] = CookieChunking.filterChunkNames(\n name,\n store.getAll().map((cookie: {name: string}) => cookie.name),\n );\n const newChunks: Record<string, string> = CookieChunking.split(name, value);\n const newNames = new Set<string>(Object.keys(newChunks));\n\n for (const existingName of existing) {\n if (!newNames.has(existingName)) {\n store.delete(existingName);\n }\n }\n\n for (const [chunkName, chunkValue] of Object.entries(newChunks)) {\n store.set(chunkName, chunkValue, options);\n }\n}\n\n/**\n * Delete a cookie that may have been chunked — clears the base cookie name\n * and every numbered chunk present in `store`.\n */\nexport function deleteChunkedCookie(store: ChunkedCookieWriter, name: string): void {\n const existing: string[] = CookieChunking.filterChunkNames(\n name,\n store.getAll().map((cookie: {name: string}) => cookie.name),\n );\n\n if (existing.length === 0) {\n store.delete(name);\n return;\n }\n\n for (const existingName of existing) {\n store.delete(existingName);\n }\n}\n"],"mappings":";;;;;;;;AA8BA,SAAgB,iBAAiB,OAA4B,MAAkC;AAC7F,QAAO,eAAe,KAAK,OAAO,eAAuB,MAAM,IAAI,WAAW,EAAE,MAAM;;;;;;;;;;;;;;AAexF,SAAgB,iBACd,OACA,MACA,OACA,SACM;CACN,MAAMA,WAAqB,eAAe,iBACxC,MACA,MAAM,QAAQ,CAAC,KAAK,WAA2B,OAAO,KAAK,CAC5D;CACD,MAAMC,YAAoC,eAAe,MAAM,MAAM,MAAM;CAC3E,MAAM,WAAW,IAAI,IAAY,OAAO,KAAK,UAAU,CAAC;AAExD,MAAK,MAAM,gBAAgB,SACzB,KAAI,CAAC,SAAS,IAAI,aAAa,CAC7B,OAAM,OAAO,aAAa;AAI9B,MAAK,MAAM,CAAC,WAAW,eAAe,OAAO,QAAQ,UAAU,CAC7D,OAAM,IAAI,WAAW,YAAY,QAAQ;;;;;;AAQ7C,SAAgB,oBAAoB,OAA4B,MAAoB;CAClF,MAAMD,WAAqB,eAAe,iBACxC,MACA,MAAM,QAAQ,CAAC,KAAK,WAA2B,OAAO,KAAK,CAC5D;AAED,KAAI,SAAS,WAAW,GAAG;AACzB,QAAM,OAAO,KAAK;AAClB;;AAGF,MAAK,MAAM,gBAAgB,SACzB,OAAM,OAAO,aAAa"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessionUtils.d.ts","sourceRoot":"","sources":["../../src/utils/sessionUtils.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"sessionUtils.d.ts","sourceRoot":"","sources":["../../src/utils/sessionUtils.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAExC,OAAuB,EAAC,mBAAmB,EAAC,MAAM,kBAAkB,CAAC;AAErE;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAU,SAAS,WAAW,KAAG,OAAO,CAAC,OAAO,CAY3E,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GAAU,SAAS,WAAW,KAAG,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAWzG,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,GAAU,SAAS,WAAW,KAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAY9F,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GAAU,SAAS,WAAW,KAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAYhG,CAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getChunkedCookie } from "./chunkedCookie.js";
|
|
1
2
|
import SessionManager_default from "./SessionManager.js";
|
|
2
3
|
|
|
3
4
|
//#region src/utils/sessionUtils.ts
|
|
@@ -10,7 +11,7 @@ import SessionManager_default from "./SessionManager.js";
|
|
|
10
11
|
*/
|
|
11
12
|
const getSessionFromRequest = async (request) => {
|
|
12
13
|
try {
|
|
13
|
-
const sessionToken = request.cookies
|
|
14
|
+
const sessionToken = getChunkedCookie(request.cookies, SessionManager_default.getSessionCookieName());
|
|
14
15
|
if (!sessionToken) return;
|
|
15
16
|
return await SessionManager_default.verifySessionToken(sessionToken);
|
|
16
17
|
} catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessionUtils.js","names":["sessionToken: string | undefined","SessionManager","sessionPayload: SessionTokenPayload | undefined"],"sources":["../../src/utils/sessionUtils.ts"],"sourcesContent":["// Copyright 2025 The ThunderID Authors\n// SPDX-License-Identifier: Apache-2.0\n\nimport {NextRequest} from 'next/server';\nimport SessionManager, {SessionTokenPayload} from './SessionManager';\n\n/**\n * Checks if a request has a valid session cookie (JWT).\n * This verifies the JWT signature and expiration.\n *\n * @param request - The Next.js request object\n * @returns True if a valid session exists, false otherwise\n */\nexport const hasValidSession = async (request: NextRequest): Promise<boolean> => {\n try {\n const sessionToken: string | undefined = request.cookies
|
|
1
|
+
{"version":3,"file":"sessionUtils.js","names":["sessionToken: string | undefined","SessionManager","sessionPayload: SessionTokenPayload | undefined"],"sources":["../../src/utils/sessionUtils.ts"],"sourcesContent":["// Copyright 2025 The ThunderID Authors\n// SPDX-License-Identifier: Apache-2.0\n\nimport {NextRequest} from 'next/server';\nimport {getChunkedCookie} from './chunkedCookie';\nimport SessionManager, {SessionTokenPayload} from './SessionManager';\n\n/**\n * Checks if a request has a valid session cookie (JWT).\n * This verifies the JWT signature and expiration.\n *\n * @param request - The Next.js request object\n * @returns True if a valid session exists, false otherwise\n */\nexport const hasValidSession = async (request: NextRequest): Promise<boolean> => {\n try {\n const sessionToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName());\n if (!sessionToken) {\n return false;\n }\n\n await SessionManager.verifySessionToken(sessionToken);\n return true;\n } catch {\n return false;\n }\n};\n\n/**\n * Gets the session payload from the request cookies.\n * This includes user ID, session ID, and scopes.\n *\n * @param request - The Next.js request object\n * @returns The session payload if valid, undefined otherwise\n */\nexport const getSessionFromRequest = async (request: NextRequest): Promise<SessionTokenPayload | undefined> => {\n try {\n const sessionToken: string | undefined = getChunkedCookie(request.cookies, SessionManager.getSessionCookieName());\n if (!sessionToken) {\n return undefined;\n }\n\n return await SessionManager.verifySessionToken(sessionToken);\n } catch {\n return undefined;\n }\n};\n\n/**\n * Gets the session ID from the request cookies (legacy support).\n * First tries to get from JWT session, then falls back to legacy session ID cookie.\n *\n * @param request - The Next.js request object\n * @returns The session ID if it exists, undefined otherwise\n */\nexport const getSessionIdFromRequest = async (request: NextRequest): Promise<string | undefined> => {\n try {\n const sessionPayload: SessionTokenPayload | undefined = await getSessionFromRequest(request);\n\n if (sessionPayload) {\n return sessionPayload.sessionId;\n }\n\n return await Promise.resolve(undefined);\n } catch {\n return Promise.resolve(undefined);\n }\n};\n\n/**\n * Gets the temporary session ID from request cookies.\n *\n * @param request - The Next.js request object\n * @returns The temporary session ID if valid, undefined otherwise\n */\nexport const getTempSessionFromRequest = async (request: NextRequest): Promise<string | undefined> => {\n try {\n const tempToken: string | undefined = request.cookies.get(SessionManager.getTempSessionCookieName())?.value;\n if (!tempToken) {\n return undefined;\n }\n\n const tempSession: {sessionId: string} = await SessionManager.verifyTempSession(tempToken);\n return tempSession.sessionId;\n } catch {\n return undefined;\n }\n};\n"],"mappings":";;;;;;;;;;;AAmCA,MAAa,wBAAwB,OAAO,YAAmE;AAC7G,KAAI;EACF,MAAMA,eAAmC,iBAAiB,QAAQ,SAASC,uBAAe,sBAAsB,CAAC;AACjH,MAAI,CAAC,aACH;AAGF,SAAO,MAAMA,uBAAe,mBAAmB,aAAa;SACtD;AACN;;;;;;;;;;AAWJ,MAAa,0BAA0B,OAAO,YAAsD;AAClG,KAAI;EACF,MAAMC,iBAAkD,MAAM,sBAAsB,QAAQ;AAE5F,MAAI,eACF,QAAO,eAAe;AAGxB,SAAO,MAAM,QAAQ,QAAQ,OAAU;SACjC;AACN,SAAO,QAAQ,QAAQ,OAAU"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thunderid/nextjs",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Next.js SDK for ThunderID",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"thunderid",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"@types/react": "19.2.14",
|
|
41
41
|
"jose": "6.2.3",
|
|
42
42
|
"tslib": "2.8.1",
|
|
43
|
-
"@thunderid/node": "^1.0.
|
|
44
|
-
"@thunderid/react": "^1.0.
|
|
43
|
+
"@thunderid/node": "^1.0.5",
|
|
44
|
+
"@thunderid/react": "^1.0.5"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@thunderid/eslint-plugin": "1.0.0",
|