@usehercules/auth-tanstack 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Grant Gurvis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ import { IDToken } from "openid-client";
2
+
3
+ //#region src/types.d.ts
4
+ type User = {};
5
+ type Impersonator = {};
6
+ type Session = {};
7
+ type AuthResult = {};
8
+ type BaseTokenClaims = {};
9
+ type CustomClaims = {};
10
+ //#endregion
11
+ //#region src/server/types.d.ts
12
+ interface HandleSignInOptions {
13
+ redirectUri?: string;
14
+ scope?: string;
15
+ }
16
+ interface HandleCallbackOptions {
17
+ returnPathname?: string;
18
+ onSuccess?: (data: HandleAuthSuccessData) => void | Promise<void>;
19
+ onError?: (params: {
20
+ error?: unknown;
21
+ request: Request;
22
+ }) => Response | Promise<Response>;
23
+ errorRedirectUrl?: string;
24
+ }
25
+ interface HandleAuthSuccessData {
26
+ accessToken: string;
27
+ idToken?: string;
28
+ refreshToken?: string;
29
+ expiresIn?: number;
30
+ scope?: string;
31
+ claims?: IDToken;
32
+ state?: string;
33
+ }
34
+ //#endregion
35
+ //#region src/server/server.d.ts
36
+ declare function handleSignInRoute(options?: HandleSignInOptions): ({
37
+ request
38
+ }: {
39
+ request: Request;
40
+ }) => Promise<Response>;
41
+ declare function handleCallbackRoute(options?: HandleCallbackOptions): ({
42
+ request
43
+ }: {
44
+ request: Request;
45
+ }) => Promise<Response>;
46
+ //#endregion
47
+ export { type AuthResult, type BaseTokenClaims, type CustomClaims, type HandleAuthSuccessData, type HandleCallbackOptions, type HandleSignInOptions, type Impersonator, type Session, type User, handleCallbackRoute, handleSignInRoute };
48
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/server/types.ts","../src/server/server.ts"],"mappings":";;;KAAY,IAAA;AAAA,KACA,YAAA;AAAA,KACA,OAAA;AAAA,KACA,UAAA;AAAA,KACA,eAAA;AAAA,KACA,YAAA;;;UCHK,mBAAA;EAUf,WAAA;EAKA,KAAK;AAAA;AAAA,UAGU,qBAAA;EACf,cAAA;EACA,SAAA,IAAa,IAAA,EAAM,qBAAA,YAAiC,OAAA;EAUpD,OAAA,IAAW,MAAA;IAAU,KAAA;IAAiB,OAAA,EAAS,OAAA;EAAA,MAAc,QAAA,GAAW,OAAA,CAAQ,QAAA;EAqBhF,gBAAA;AAAA;AAAA,UAUe,qBAAA;EAEf,WAAA;EAEA,OAAA;EAEA,YAAA;EAEA,SAAA;EAEA,KAAA;EAKA,MAAA,GAAS,OAAO;EAEhB,KAAA;AAAA;;;iBCuDc,iBAAA,CAAkB,OAAA,GAAU,mBAAA;EAC5B;AAAA;EAAe,OAAA,EAAS,OAAA;AAAA,MAAY,OAAA,CAAQ,QAAA;AAAA,iBA4E5C,mBAAA,CAAoB,OAAA,GAAU,qBAAA;EAC9B;AAAA;EAAe,OAAA,EAAS,OAAA;AAAA,MAAY,OAAA,CAAQ,QAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,252 @@
1
+ import * as client from "openid-client";
2
+ //#region src/server/cookie-utils.ts
3
+ function parseCookies(cookieHeader) {
4
+ if (!cookieHeader.trim()) return {};
5
+ return Object.fromEntries(cookieHeader.split(";").map((cookie) => {
6
+ const [key, ...valueParts] = cookie.trim().split("=");
7
+ return [key, valueParts.join("=")];
8
+ }));
9
+ }
10
+ /**
11
+ * Parse only the cookie names from a `Cookie` header, skipping the values.
12
+ *
13
+ * Use this when you need the set of cookie names but not their contents — it
14
+ * avoids allocating the (potentially large) value strings that `parseCookies`
15
+ * materializes. Relevant on the PKCE-verifier eviction path, where the header
16
+ * can carry many large encrypted verifier blobs whose values are irrelevant.
17
+ */
18
+ function parseCookieNames(cookieHeader) {
19
+ if (!cookieHeader.trim()) return [];
20
+ return cookieHeader.split(";").map((cookie) => {
21
+ const eq = cookie.indexOf("=");
22
+ return (eq === -1 ? cookie : cookie.slice(0, eq)).trim();
23
+ }).filter(Boolean);
24
+ }
25
+ /**
26
+ * Serialize a cookie name/value pair into a `Set-Cookie` header string.
27
+ *
28
+ * Only the attributes needed by this package are supported. `maxAge` is floored
29
+ * to a whole number of seconds; pass `0` to expire a cookie immediately.
30
+ */
31
+ function serializeCookie(name, value, options = {}) {
32
+ const parts = [`${name}=${value}`];
33
+ if (options.path) parts.push(`Path=${options.path}`);
34
+ if (options.maxAge !== void 0) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
35
+ if (options.httpOnly) parts.push("HttpOnly");
36
+ if (options.secure) parts.push("Secure");
37
+ if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
38
+ return parts.join("; ");
39
+ }
40
+ //#endregion
41
+ //#region src/server/server.ts
42
+ /**
43
+ * OIDC issuer URL used for discovery (`{issuer}/.well-known/openid-configuration`).
44
+ * For Amazon Cognito this is the user-pool issuer
45
+ * (`https://cognito-idp.<region>.amazonaws.com/<userPoolId>`), NOT the hosted-UI
46
+ * domain — the hosted domain does not serve the discovery document.
47
+ */
48
+ const ISSUER_URL_ENV = "HERCULES_AUTH_ISSUER_URL";
49
+ /** OAuth client (app client) identifier. */
50
+ const CLIENT_ID_ENV = "HERCULES_AUTH_CLIENT_ID";
51
+ /** OAuth client secret. Optional — omit for a public (PKCE-only) client. */
52
+ const CLIENT_SECRET_ENV = "HERCULES_AUTH_CLIENT_SECRET";
53
+ /**
54
+ * Prefix for per-flow PKCE cookies. Each pending sign-in stores its
55
+ * `code_verifier` under `${PKCE_COOKIE_PREFIX}${state}`, so concurrent flows
56
+ * (a double-click, a retry, a second tab) keep independent cookies instead of
57
+ * overwriting one shared name and invalidating each other.
58
+ */
59
+ const PKCE_COOKIE_PREFIX = "hercules_pkce_";
60
+ /** Cookie holding the authenticated session token. */
61
+ const AUTH_COOKIE = "hercules_session";
62
+ /** Where to send the user once the callback completes. */
63
+ const DEFAULT_REDIRECT = "/";
64
+ /** Callback route the provider returns to, unless overridden. */
65
+ const DEFAULT_CALLBACK_PATH = "/api/auth/callback";
66
+ /** OAuth scopes requested when none are configured. */
67
+ const DEFAULT_SCOPE = "openid profile email";
68
+ /** Lifetime (seconds) of a pending sign-in's PKCE cookie. */
69
+ const SIGN_IN_COOKIE_MAX_AGE = 600;
70
+ /**
71
+ * Cap on simultaneously pending sign-in flows. Beyond this we expire surplus
72
+ * verifier cookies on the next sign-in so the request `Cookie` header cannot
73
+ * grow without bound from abandoned attempts.
74
+ */
75
+ const MAX_PENDING_SIGN_INS = 10;
76
+ /** Cookie name holding the PKCE verifier for the flow identified by `state`. */
77
+ function pkceCookieName(state) {
78
+ return PKCE_COOKIE_PREFIX + state;
79
+ }
80
+ function requireEnv(name) {
81
+ const value = process.env[name];
82
+ if (!value) throw new Error(`[auth-tanstack] Missing required environment variable: ${name}`);
83
+ return value;
84
+ }
85
+ let configPromise;
86
+ function getConfig() {
87
+ if (!configPromise) {
88
+ const issuerUrl = new URL(requireEnv(ISSUER_URL_ENV));
89
+ const clientId = requireEnv(CLIENT_ID_ENV);
90
+ const clientSecret = process.env[CLIENT_SECRET_ENV];
91
+ configPromise = (clientSecret ? client.discovery(issuerUrl, clientId, clientSecret) : client.discovery(issuerUrl, clientId, void 0, client.None())).catch((error) => {
92
+ configPromise = void 0;
93
+ throw error;
94
+ });
95
+ }
96
+ return configPromise;
97
+ }
98
+ /** Append a `Set-Cookie` that immediately expires the named cookie. */
99
+ function deleteCookie(headers, name) {
100
+ headers.append("Set-Cookie", serializeCookie(name, "", {
101
+ path: "/",
102
+ maxAge: 0
103
+ }));
104
+ }
105
+ /**
106
+ * Resolve a callback failure into a Response, honoring the caller's error
107
+ * handling preferences (see {@link HandleCallbackOptions}). `onError` wins over
108
+ * `errorRedirectUrl`, which in turn wins over the default JSON error response.
109
+ *
110
+ * When `clearCookieName` is given (the failed flow's verifier cookie) it is
111
+ * expired; other pending sign-in flows are left untouched.
112
+ */
113
+ async function handleError(request, status, message, error, options, clearCookieName) {
114
+ if (options?.onError) return options.onError({
115
+ error,
116
+ request
117
+ });
118
+ if (options?.errorRedirectUrl) try {
119
+ const location = new URL(options.errorRedirectUrl, new URL(request.url).origin).toString();
120
+ const headers = new Headers({ Location: location });
121
+ if (clearCookieName) deleteCookie(headers, clearCookieName);
122
+ return new Response(null, {
123
+ status: 302,
124
+ headers
125
+ });
126
+ } catch {
127
+ console.warn(`[auth-tanstack] Ignoring malformed errorRedirectUrl: ${options.errorRedirectUrl}`);
128
+ }
129
+ const headers = new Headers({ "Content-Type": "application/json" });
130
+ if (clearCookieName) deleteCookie(headers, clearCookieName);
131
+ return new Response(JSON.stringify({ error: message }), {
132
+ status,
133
+ headers
134
+ });
135
+ }
136
+ /**
137
+ * Build a TanStack route handler that initiates the OIDC login.
138
+ *
139
+ * Navigating to (redirecting to) the route this is mounted on starts the
140
+ * Authorization Code + PKCE flow: it generates a fresh `code_verifier` and
141
+ * `state`, stashes them in short-lived cookies for {@link handleCallbackRoute}
142
+ * to consume, and redirects the user-agent to the provider's authorization
143
+ * endpoint.
144
+ *
145
+ * @public
146
+ */
147
+ function handleSignInRoute(options) {
148
+ return async ({ request }) => {
149
+ return handleSignInInternal(request, options);
150
+ };
151
+ }
152
+ async function handleSignInInternal(request, options) {
153
+ const url = new URL(request.url);
154
+ const codeVerifier = client.randomPKCECodeVerifier();
155
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
156
+ const state = client.randomState();
157
+ const redirectUri = new URL(options?.redirectUri ?? DEFAULT_CALLBACK_PATH, url.origin).toString();
158
+ let authorizationUrl;
159
+ try {
160
+ const config = await getConfig();
161
+ authorizationUrl = client.buildAuthorizationUrl(config, {
162
+ redirect_uri: redirectUri,
163
+ scope: options?.scope ?? DEFAULT_SCOPE,
164
+ state,
165
+ code_challenge: codeChallenge,
166
+ code_challenge_method: "S256"
167
+ });
168
+ } catch {
169
+ return new Response(JSON.stringify({ error: "Failed to start sign-in" }), {
170
+ status: 500,
171
+ headers: { "Content-Type": "application/json" }
172
+ });
173
+ }
174
+ const headers = new Headers({ Location: authorizationUrl.toString() });
175
+ headers.append("Set-Cookie", serializeCookie(pkceCookieName(state), codeVerifier, {
176
+ httpOnly: true,
177
+ secure: url.protocol === "https:",
178
+ sameSite: "Lax",
179
+ path: "/",
180
+ maxAge: SIGN_IN_COOKIE_MAX_AGE
181
+ }));
182
+ const pending = parseCookieNames(request.headers.get("cookie") ?? "").filter((name) => name.startsWith(PKCE_COOKIE_PREFIX));
183
+ for (const name of pending.slice(MAX_PENDING_SIGN_INS - 1)) deleteCookie(headers, name);
184
+ return new Response(null, {
185
+ status: 302,
186
+ headers
187
+ });
188
+ }
189
+ /**
190
+ * Build a TanStack route handler for the OAuth/OIDC callback.
191
+ *
192
+ * The handler completes the authorization-code grant using the PKCE
193
+ * `code_verifier` and `state` stashed in cookies during sign-in, invokes
194
+ * {@link HandleCallbackOptions.onSuccess} with the token response, stores the
195
+ * resulting session token in an HttpOnly cookie, clears the one-time sign-in
196
+ * cookies, and redirects the user to {@link HandleCallbackOptions.returnPathname}.
197
+ *
198
+ * @public
199
+ */
200
+ function handleCallbackRoute(options) {
201
+ return async ({ request }) => {
202
+ return handleCallbackInternal(request, options);
203
+ };
204
+ }
205
+ async function handleCallbackInternal(request, options) {
206
+ const url = new URL(request.url);
207
+ if (!url.searchParams.get("code")) return handleError(request, 400, "Missing code parameter", void 0, options);
208
+ const state = url.searchParams.get("state");
209
+ if (!state) return handleError(request, 400, "Missing state parameter", void 0, options);
210
+ const verifierCookieName = pkceCookieName(state);
211
+ const pkceCodeVerifier = parseCookies(request.headers.get("cookie") ?? "")[verifierCookieName];
212
+ if (!pkceCodeVerifier) return handleError(request, 400, "Unknown or expired sign-in state", void 0, options);
213
+ const checks = {
214
+ pkceCodeVerifier,
215
+ expectedState: state
216
+ };
217
+ let tokens;
218
+ try {
219
+ const config = await getConfig();
220
+ tokens = await client.authorizationCodeGrant(config, url, checks);
221
+ } catch (error) {
222
+ return handleError(request, 500, "Token exchange failed", error, options, verifierCookieName);
223
+ }
224
+ const data = {
225
+ accessToken: tokens.access_token,
226
+ idToken: tokens.id_token,
227
+ refreshToken: tokens.refresh_token,
228
+ expiresIn: tokens.expires_in,
229
+ scope: tokens.scope,
230
+ claims: tokens.claims(),
231
+ state
232
+ };
233
+ await options?.onSuccess?.(data);
234
+ const secure = url.protocol === "https:";
235
+ const headers = new Headers({ Location: options?.returnPathname ?? DEFAULT_REDIRECT });
236
+ headers.append("Set-Cookie", serializeCookie(AUTH_COOKIE, data.idToken ?? data.accessToken, {
237
+ httpOnly: true,
238
+ secure,
239
+ sameSite: "Lax",
240
+ path: "/",
241
+ maxAge: data.expiresIn
242
+ }));
243
+ deleteCookie(headers, verifierCookieName);
244
+ return new Response(null, {
245
+ status: 302,
246
+ headers
247
+ });
248
+ }
249
+ //#endregion
250
+ export { handleCallbackRoute, handleSignInRoute };
251
+
252
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/server/cookie-utils.ts","../src/server/server.ts"],"sourcesContent":["export function parseCookies(cookieHeader: string): Record<string, string> {\n if (!cookieHeader.trim()) return {};\n return Object.fromEntries(\n cookieHeader.split(\";\").map((cookie) => {\n const [key, ...valueParts] = cookie.trim().split(\"=\");\n return [key, valueParts.join(\"=\")];\n }),\n );\n}\n\n/**\n * Parse only the cookie names from a `Cookie` header, skipping the values.\n *\n * Use this when you need the set of cookie names but not their contents — it\n * avoids allocating the (potentially large) value strings that `parseCookies`\n * materializes. Relevant on the PKCE-verifier eviction path, where the header\n * can carry many large encrypted verifier blobs whose values are irrelevant.\n */\nexport function parseCookieNames(cookieHeader: string): string[] {\n if (!cookieHeader.trim()) return [];\n return cookieHeader\n .split(\";\")\n .map((cookie) => {\n const eq = cookie.indexOf(\"=\");\n return (eq === -1 ? cookie : cookie.slice(0, eq)).trim();\n })\n .filter(Boolean);\n}\n\nexport interface CookieOptions {\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"Strict\" | \"Lax\" | \"None\";\n path?: string;\n maxAge?: number;\n}\n\n/**\n * Serialize a cookie name/value pair into a `Set-Cookie` header string.\n *\n * Only the attributes needed by this package are supported. `maxAge` is floored\n * to a whole number of seconds; pass `0` to expire a cookie immediately.\n */\nexport function serializeCookie(\n name: string,\n value: string,\n options: CookieOptions = {},\n): string {\n const parts = [`${name}=${value}`];\n if (options.path) parts.push(`Path=${options.path}`);\n if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);\n if (options.httpOnly) parts.push(\"HttpOnly\");\n if (options.secure) parts.push(\"Secure\");\n if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);\n return parts.join(\"; \");\n}\n","import * as client from \"openid-client\";\nimport { parseCookieNames, parseCookies, serializeCookie } from \"./cookie-utils\";\nimport type { HandleAuthSuccessData, HandleCallbackOptions, HandleSignInOptions } from \"./types\";\n\nexport type { HandleAuthSuccessData, HandleCallbackOptions, HandleSignInOptions } from \"./types\";\n\n/**\n * OIDC issuer URL used for discovery (`{issuer}/.well-known/openid-configuration`).\n * For Amazon Cognito this is the user-pool issuer\n * (`https://cognito-idp.<region>.amazonaws.com/<userPoolId>`), NOT the hosted-UI\n * domain — the hosted domain does not serve the discovery document.\n */\nconst ISSUER_URL_ENV = \"HERCULES_AUTH_ISSUER_URL\";\n/** OAuth client (app client) identifier. */\nconst CLIENT_ID_ENV = \"HERCULES_AUTH_CLIENT_ID\";\n/** OAuth client secret. Optional — omit for a public (PKCE-only) client. */\nconst CLIENT_SECRET_ENV = \"HERCULES_AUTH_CLIENT_SECRET\";\n\n/**\n * Prefix for per-flow PKCE cookies. Each pending sign-in stores its\n * `code_verifier` under `${PKCE_COOKIE_PREFIX}${state}`, so concurrent flows\n * (a double-click, a retry, a second tab) keep independent cookies instead of\n * overwriting one shared name and invalidating each other.\n */\nconst PKCE_COOKIE_PREFIX = \"hercules_pkce_\";\n/** Cookie holding the authenticated session token. */\nconst AUTH_COOKIE = \"hercules_session\";\n\n/** Where to send the user once the callback completes. */\nconst DEFAULT_REDIRECT = \"/\";\n/** Callback route the provider returns to, unless overridden. */\nconst DEFAULT_CALLBACK_PATH = \"/api/auth/callback\";\n/** OAuth scopes requested when none are configured. */\nconst DEFAULT_SCOPE = \"openid profile email\";\n/** Lifetime (seconds) of a pending sign-in's PKCE cookie. */\nconst SIGN_IN_COOKIE_MAX_AGE = 600;\n/**\n * Cap on simultaneously pending sign-in flows. Beyond this we expire surplus\n * verifier cookies on the next sign-in so the request `Cookie` header cannot\n * grow without bound from abandoned attempts.\n */\nconst MAX_PENDING_SIGN_INS = 10;\n\n/** Cookie name holding the PKCE verifier for the flow identified by `state`. */\nfunction pkceCookieName(state: string): string {\n return PKCE_COOKIE_PREFIX + state;\n}\n\nfunction requireEnv(name: string): string {\n const value = process.env[name];\n if (!value) {\n throw new Error(`[auth-tanstack] Missing required environment variable: ${name}`);\n }\n return value;\n}\n\n// Discovery is a network round-trip and the resolved metadata is static for the\n// lifetime of the process, so resolve the Configuration once and reuse it.\nlet configPromise: Promise<client.Configuration> | undefined;\nfunction getConfig(): Promise<client.Configuration> {\n if (!configPromise) {\n const issuerUrl = new URL(requireEnv(ISSUER_URL_ENV));\n const clientId = requireEnv(CLIENT_ID_ENV);\n const clientSecret = process.env[CLIENT_SECRET_ENV];\n\n // A public client authenticates with PKCE alone (no secret); a confidential\n // client authenticates the token request with its secret.\n const discovered = clientSecret\n ? client.discovery(issuerUrl, clientId, clientSecret)\n : client.discovery(issuerUrl, clientId, undefined, client.None());\n\n configPromise = discovered.catch((error) => {\n // Don't cache a failed discovery — let the next request retry instead of\n // permanently poisoning every sign-in and callback.\n configPromise = undefined;\n throw error;\n });\n }\n return configPromise;\n}\n\n/** Append a `Set-Cookie` that immediately expires the named cookie. */\nfunction deleteCookie(headers: Headers, name: string): void {\n headers.append(\"Set-Cookie\", serializeCookie(name, \"\", { path: \"/\", maxAge: 0 }));\n}\n\n/**\n * Resolve a callback failure into a Response, honoring the caller's error\n * handling preferences (see {@link HandleCallbackOptions}). `onError` wins over\n * `errorRedirectUrl`, which in turn wins over the default JSON error response.\n *\n * When `clearCookieName` is given (the failed flow's verifier cookie) it is\n * expired; other pending sign-in flows are left untouched.\n */\nasync function handleError(\n request: Request,\n status: 400 | 500,\n message: string,\n error: unknown,\n options?: HandleCallbackOptions,\n clearCookieName?: string,\n): Promise<Response> {\n // `onError` is intentionally not wrapped — errors it throws propagate.\n if (options?.onError) {\n return options.onError({ error, request });\n }\n\n if (options?.errorRedirectUrl) {\n try {\n const location = new URL(options.errorRedirectUrl, new URL(request.url).origin).toString();\n const headers = new Headers({ Location: location });\n if (clearCookieName) deleteCookie(headers, clearCookieName);\n return new Response(null, { status: 302, headers });\n } catch {\n // Malformed config value — warn and fall back to the JSON error response.\n console.warn(`[auth-tanstack] Ignoring malformed errorRedirectUrl: ${options.errorRedirectUrl}`);\n }\n }\n\n const headers = new Headers({ \"Content-Type\": \"application/json\" });\n if (clearCookieName) deleteCookie(headers, clearCookieName);\n return new Response(JSON.stringify({ error: message }), { status, headers });\n}\n\n/**\n * Build a TanStack route handler that initiates the OIDC login.\n *\n * Navigating to (redirecting to) the route this is mounted on starts the\n * Authorization Code + PKCE flow: it generates a fresh `code_verifier` and\n * `state`, stashes them in short-lived cookies for {@link handleCallbackRoute}\n * to consume, and redirects the user-agent to the provider's authorization\n * endpoint.\n *\n * @public\n */\nexport function handleSignInRoute(options?: HandleSignInOptions) {\n return async ({ request }: { request: Request }): Promise<Response> => {\n return handleSignInInternal(request, options);\n };\n}\n\nasync function handleSignInInternal(\n request: Request,\n options?: HandleSignInOptions,\n): Promise<Response> {\n const url = new URL(request.url);\n\n // These must be unique per authorization request and tied to the user-agent.\n const codeVerifier = client.randomPKCECodeVerifier();\n const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);\n const state = client.randomState();\n\n const redirectUri = new URL(options?.redirectUri ?? DEFAULT_CALLBACK_PATH, url.origin).toString();\n\n let authorizationUrl: URL;\n try {\n const config = await getConfig();\n authorizationUrl = client.buildAuthorizationUrl(config, {\n redirect_uri: redirectUri,\n scope: options?.scope ?? DEFAULT_SCOPE,\n state,\n code_challenge: codeChallenge,\n code_challenge_method: \"S256\",\n });\n } catch {\n return new Response(JSON.stringify({ error: \"Failed to start sign-in\" }), {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n const headers = new Headers({ Location: authorizationUrl.toString() });\n\n // Stash this flow's PKCE verifier under a state-keyed cookie so concurrent\n // sign-ins keep separate verifiers instead of clobbering a shared name.\n // SameSite=Lax so it survives the top-level redirect back from the provider.\n headers.append(\n \"Set-Cookie\",\n serializeCookie(pkceCookieName(state), codeVerifier, {\n httpOnly: true,\n secure: url.protocol === \"https:\",\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: SIGN_IN_COOKIE_MAX_AGE,\n }),\n );\n\n // Bound the number of pending verifier cookies. Abandoned flows would\n // otherwise linger until they expire and could overflow the cookie header; we\n // can't tell their age, so once over the cap we expire the surplus and keep\n // this fresh flow plus a handful of genuinely concurrent ones.\n const pending = parseCookieNames(request.headers.get(\"cookie\") ?? \"\").filter((name) =>\n name.startsWith(PKCE_COOKIE_PREFIX),\n );\n for (const name of pending.slice(MAX_PENDING_SIGN_INS - 1)) {\n deleteCookie(headers, name);\n }\n\n return new Response(null, { status: 302, headers });\n}\n\n/**\n * Build a TanStack route handler for the OAuth/OIDC callback.\n *\n * The handler completes the authorization-code grant using the PKCE\n * `code_verifier` and `state` stashed in cookies during sign-in, invokes\n * {@link HandleCallbackOptions.onSuccess} with the token response, stores the\n * resulting session token in an HttpOnly cookie, clears the one-time sign-in\n * cookies, and redirects the user to {@link HandleCallbackOptions.returnPathname}.\n *\n * @public\n */\nexport function handleCallbackRoute(options?: HandleCallbackOptions) {\n return async ({ request }: { request: Request }): Promise<Response> => {\n return handleCallbackInternal(request, options);\n };\n}\n\nasync function handleCallbackInternal(\n request: Request,\n options?: HandleCallbackOptions,\n): Promise<Response> {\n const url = new URL(request.url);\n const code = url.searchParams.get(\"code\");\n\n if (!code) {\n return handleError(request, 400, \"Missing code parameter\", undefined, options);\n }\n\n // The provider echoes back the `state` from the authorization request; use it\n // to locate the matching pending flow's PKCE verifier. Only a sign-in we\n // started in this browser could have set that state-keyed cookie, so its\n // presence both proves the callback belongs to that request (CSRF defense)\n // and tells concurrent flows apart. Pass `state` as `expectedState` so the\n // grant also rejects a response whose `state` is missing or mismatched.\n const state = url.searchParams.get(\"state\");\n if (!state) {\n return handleError(request, 400, \"Missing state parameter\", undefined, options);\n }\n\n const verifierCookieName = pkceCookieName(state);\n const pkceCodeVerifier = parseCookies(request.headers.get(\"cookie\") ?? \"\")[verifierCookieName];\n if (!pkceCodeVerifier) {\n return handleError(request, 400, \"Unknown or expired sign-in state\", undefined, options);\n }\n\n const checks: client.AuthorizationCodeGrantChecks = { pkceCodeVerifier, expectedState: state };\n\n let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;\n try {\n const config = await getConfig();\n tokens = await client.authorizationCodeGrant(config, url, checks);\n } catch (error) {\n return handleError(request, 500, \"Token exchange failed\", error, options, verifierCookieName);\n }\n\n // `access_token` is always present in a successful token response, so the\n // session token below is guaranteed to be a string.\n const data: HandleAuthSuccessData = {\n accessToken: tokens.access_token,\n idToken: tokens.id_token,\n refreshToken: tokens.refresh_token,\n expiresIn: tokens.expires_in,\n scope: tokens.scope,\n claims: tokens.claims(),\n state,\n };\n\n await options?.onSuccess?.(data);\n\n const secure = url.protocol === \"https:\";\n const headers = new Headers({ Location: options?.returnPathname ?? DEFAULT_REDIRECT });\n\n // Persist the session token (prefer the ID token) for subsequent requests.\n headers.append(\n \"Set-Cookie\",\n serializeCookie(AUTH_COOKIE, data.idToken ?? data.accessToken, {\n httpOnly: true,\n secure,\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: data.expiresIn,\n }),\n );\n\n // Clear only this flow's verifier; other concurrent sign-ins keep theirs.\n deleteCookie(headers, verifierCookieName);\n\n return new Response(null, { status: 302, headers });\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@usehercules/auth-tanstack",
3
+ "version": "0.0.1",
4
+ "description": "TanStack authentication utilities for Hercules applications",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": "./dist/index.mjs",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "author": "Hercules Team",
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/withzeusai/hercules-js.git",
16
+ "directory": "packages/auth-tanstack"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "tsconfig.json",
22
+ "tsdown.config.ts"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.13.2",
29
+ "tsdown": "^0.22.3",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.9"
32
+ },
33
+ "dependencies": {
34
+ "@tanstack/react-router": "^1.170.16",
35
+ "@tanstack/react-start": "^1.168.26",
36
+ "oauth4webapi": "^3.8.6",
37
+ "openid-client": "^6.8.4"
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown",
41
+ "dev": "tsdown --watch",
42
+ "test": "vitest --run"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type {
2
+ User,
3
+ Impersonator,
4
+ Session,
5
+ AuthResult,
6
+ BaseTokenClaims,
7
+ CustomClaims,
8
+ } from "./types";
9
+ export {
10
+ type HandleAuthSuccessData,
11
+ type HandleCallbackOptions,
12
+ type HandleSignInOptions,
13
+ handleCallbackRoute,
14
+ handleSignInRoute,
15
+ } from "./server/server";
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseCookies, parseCookieNames, serializeCookie } from "./cookie-utils";
3
+
4
+ describe("parseCookies", () => {
5
+ it("parses a single cookie", () => {
6
+ expect(parseCookies("a=1")).toEqual({ a: "1" });
7
+ });
8
+
9
+ it("parses multiple cookies", () => {
10
+ expect(parseCookies("a=1; b=2; c=3")).toEqual({ a: "1", b: "2", c: "3" });
11
+ });
12
+
13
+ it("preserves = characters within cookie values", () => {
14
+ expect(parseCookies("token=base64==padding==")).toEqual({ token: "base64==padding==" });
15
+ });
16
+
17
+ it("returns an empty object for an empty header", () => {
18
+ expect(parseCookies("")).toEqual({});
19
+ expect(parseCookies(" ")).toEqual({});
20
+ });
21
+
22
+ it("trims whitespace around each pair", () => {
23
+ expect(parseCookies("a=1 ; b=2")).toEqual({ a: "1", b: "2" });
24
+ });
25
+ });
26
+
27
+ describe("parseCookieNames", () => {
28
+ it("returns names only, ignoring values", () => {
29
+ expect(parseCookieNames("a=1; b=2; c=3")).toEqual(["a", "b", "c"]);
30
+ });
31
+
32
+ it("handles values containing = signs", () => {
33
+ expect(parseCookieNames("token=base64==padding==")).toEqual(["token"]);
34
+ });
35
+
36
+ it("trims whitespace around each name", () => {
37
+ expect(parseCookieNames("a=1 ; b=2")).toEqual(["a", "b"]);
38
+ });
39
+
40
+ it("returns an empty array for an empty header", () => {
41
+ expect(parseCookieNames("")).toEqual([]);
42
+ expect(parseCookieNames(" ")).toEqual([]);
43
+ });
44
+
45
+ it("tolerates a valueless cookie segment", () => {
46
+ expect(parseCookieNames("a=1; flag; b=2")).toEqual(["a", "flag", "b"]);
47
+ });
48
+ });
49
+
50
+ describe("serializeCookie", () => {
51
+ it("serializes a bare name/value pair", () => {
52
+ expect(serializeCookie("a", "1")).toBe("a=1");
53
+ });
54
+
55
+ it("includes Path and Max-Age when provided", () => {
56
+ expect(serializeCookie("a", "1", { path: "/", maxAge: 3600 })).toBe(
57
+ "a=1; Path=/; Max-Age=3600",
58
+ );
59
+ });
60
+
61
+ it("appends HttpOnly, Secure, and SameSite flags", () => {
62
+ expect(
63
+ serializeCookie("session", "tok", {
64
+ httpOnly: true,
65
+ secure: true,
66
+ sameSite: "Lax",
67
+ path: "/",
68
+ }),
69
+ ).toBe("session=tok; Path=/; HttpOnly; Secure; SameSite=Lax");
70
+ });
71
+
72
+ it("floors a fractional Max-Age to whole seconds", () => {
73
+ expect(serializeCookie("a", "1", { maxAge: 59.9 })).toBe("a=1; Max-Age=59");
74
+ });
75
+
76
+ it("emits Max-Age=0 to expire a cookie", () => {
77
+ expect(serializeCookie("a", "", { path: "/", maxAge: 0 })).toBe("a=; Path=/; Max-Age=0");
78
+ });
79
+
80
+ it("omits falsy flags", () => {
81
+ expect(serializeCookie("a", "1", { httpOnly: false, secure: false })).toBe("a=1");
82
+ });
83
+ });
@@ -0,0 +1,56 @@
1
+ export function parseCookies(cookieHeader: string): Record<string, string> {
2
+ if (!cookieHeader.trim()) return {};
3
+ return Object.fromEntries(
4
+ cookieHeader.split(";").map((cookie) => {
5
+ const [key, ...valueParts] = cookie.trim().split("=");
6
+ return [key, valueParts.join("=")];
7
+ }),
8
+ );
9
+ }
10
+
11
+ /**
12
+ * Parse only the cookie names from a `Cookie` header, skipping the values.
13
+ *
14
+ * Use this when you need the set of cookie names but not their contents — it
15
+ * avoids allocating the (potentially large) value strings that `parseCookies`
16
+ * materializes. Relevant on the PKCE-verifier eviction path, where the header
17
+ * can carry many large encrypted verifier blobs whose values are irrelevant.
18
+ */
19
+ export function parseCookieNames(cookieHeader: string): string[] {
20
+ if (!cookieHeader.trim()) return [];
21
+ return cookieHeader
22
+ .split(";")
23
+ .map((cookie) => {
24
+ const eq = cookie.indexOf("=");
25
+ return (eq === -1 ? cookie : cookie.slice(0, eq)).trim();
26
+ })
27
+ .filter(Boolean);
28
+ }
29
+
30
+ export interface CookieOptions {
31
+ httpOnly?: boolean;
32
+ secure?: boolean;
33
+ sameSite?: "Strict" | "Lax" | "None";
34
+ path?: string;
35
+ maxAge?: number;
36
+ }
37
+
38
+ /**
39
+ * Serialize a cookie name/value pair into a `Set-Cookie` header string.
40
+ *
41
+ * Only the attributes needed by this package are supported. `maxAge` is floored
42
+ * to a whole number of seconds; pass `0` to expire a cookie immediately.
43
+ */
44
+ export function serializeCookie(
45
+ name: string,
46
+ value: string,
47
+ options: CookieOptions = {},
48
+ ): string {
49
+ const parts = [`${name}=${value}`];
50
+ if (options.path) parts.push(`Path=${options.path}`);
51
+ if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
52
+ if (options.httpOnly) parts.push("HttpOnly");
53
+ if (options.secure) parts.push("Secure");
54
+ if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
55
+ return parts.join("; ");
56
+ }
@@ -0,0 +1,262 @@
1
+ import * as client from "openid-client";
2
+ import { parseCookies, serializeCookie } from "./cookie-utils";
3
+ import type { HandleAuthSuccessData, HandleCallbackOptions, HandleSignInOptions } from "./types";
4
+
5
+ export type { HandleAuthSuccessData, HandleCallbackOptions, HandleSignInOptions } from "./types";
6
+
7
+ /**
8
+ * OIDC issuer URL used for discovery (`{issuer}/.well-known/openid-configuration`).
9
+ * For Amazon Cognito this is the user-pool issuer
10
+ * (`https://cognito-idp.<region>.amazonaws.com/<userPoolId>`), NOT the hosted-UI
11
+ * domain — the hosted domain does not serve the discovery document.
12
+ */
13
+ const ISSUER_URL_ENV = "HERCULES_AUTH_ISSUER_URL";
14
+ /** OAuth client (app client) identifier. */
15
+ const CLIENT_ID_ENV = "HERCULES_AUTH_CLIENT_ID";
16
+ /** OAuth client secret. Optional — omit for a public (PKCE-only) client. */
17
+ const CLIENT_SECRET_ENV = "HERCULES_AUTH_CLIENT_SECRET";
18
+
19
+ /** Cookie carrying the PKCE `code_verifier`, written when the sign-in flow began. */
20
+ const PKCE_VERIFIER_COOKIE = "hercules_pkce_verifier";
21
+ /** Cookie carrying the OAuth `state`, written when the sign-in flow began. */
22
+ const STATE_COOKIE = "hercules_oauth_state";
23
+ /** Cookie holding the authenticated session token. */
24
+ const AUTH_COOKIE = "hercules_session";
25
+
26
+ /** Where to send the user once the callback completes. */
27
+ const DEFAULT_REDIRECT = "/";
28
+ /** Callback route the provider returns to, unless overridden. */
29
+ const DEFAULT_CALLBACK_PATH = "/api/auth/callback";
30
+ /** OAuth scopes requested when none are configured. */
31
+ const DEFAULT_SCOPE = "openid profile email";
32
+ /** Lifetime (seconds) of the one-time sign-in cookies. */
33
+ const SIGN_IN_COOKIE_MAX_AGE = 600;
34
+
35
+ /** One-time sign-in cookies, cleared once the callback resolves either way. */
36
+ const SIGN_IN_COOKIES = [PKCE_VERIFIER_COOKIE, STATE_COOKIE];
37
+
38
+ function requireEnv(name: string): string {
39
+ const value = process.env[name];
40
+ if (!value) {
41
+ throw new Error(`[auth-tanstack] Missing required environment variable: ${name}`);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ // Discovery is a network round-trip and the resolved metadata is static for the
47
+ // lifetime of the process, so resolve the Configuration once and reuse it.
48
+ let configPromise: Promise<client.Configuration> | undefined;
49
+ function getConfig(): Promise<client.Configuration> {
50
+ if (!configPromise) {
51
+ const issuerUrl = new URL(requireEnv(ISSUER_URL_ENV));
52
+ const clientId = requireEnv(CLIENT_ID_ENV);
53
+ const clientSecret = process.env[CLIENT_SECRET_ENV];
54
+
55
+ // A public client authenticates with PKCE alone (no secret); a confidential
56
+ // client authenticates the token request with its secret.
57
+ const discovered = clientSecret
58
+ ? client.discovery(issuerUrl, clientId, clientSecret)
59
+ : client.discovery(issuerUrl, clientId, undefined, client.None());
60
+
61
+ configPromise = discovered.catch((error) => {
62
+ // Don't cache a failed discovery — let the next request retry instead of
63
+ // permanently poisoning every sign-in and callback.
64
+ configPromise = undefined;
65
+ throw error;
66
+ });
67
+ }
68
+ return configPromise;
69
+ }
70
+
71
+ function deleteSignInCookies(headers: Headers): void {
72
+ for (const name of SIGN_IN_COOKIES) {
73
+ headers.append("Set-Cookie", serializeCookie(name, "", { path: "/", maxAge: 0 }));
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolve a callback failure into a Response, honoring the caller's error
79
+ * handling preferences (see {@link HandleCallbackOptions}). `onError` wins over
80
+ * `errorRedirectUrl`, which in turn wins over the default JSON error response.
81
+ * All paths clear the one-time sign-in cookies.
82
+ */
83
+ async function handleError(
84
+ request: Request,
85
+ status: 400 | 500,
86
+ message: string,
87
+ error: unknown,
88
+ options?: HandleCallbackOptions,
89
+ ): Promise<Response> {
90
+ // `onError` is intentionally not wrapped — errors it throws propagate.
91
+ if (options?.onError) {
92
+ return options.onError({ error, request });
93
+ }
94
+
95
+ if (options?.errorRedirectUrl) {
96
+ try {
97
+ const location = new URL(options.errorRedirectUrl, new URL(request.url).origin).toString();
98
+ const headers = new Headers({ Location: location });
99
+ deleteSignInCookies(headers);
100
+ return new Response(null, { status: 302, headers });
101
+ } catch {
102
+ // Malformed config value — warn and fall back to the JSON error response.
103
+ console.warn(`[auth-tanstack] Ignoring malformed errorRedirectUrl: ${options.errorRedirectUrl}`);
104
+ }
105
+ }
106
+
107
+ const headers = new Headers({ "Content-Type": "application/json" });
108
+ deleteSignInCookies(headers);
109
+ return new Response(JSON.stringify({ error: message }), { status, headers });
110
+ }
111
+
112
+ /**
113
+ * Build a TanStack route handler that initiates the OIDC login.
114
+ *
115
+ * Navigating to (redirecting to) the route this is mounted on starts the
116
+ * Authorization Code + PKCE flow: it generates a fresh `code_verifier` and
117
+ * `state`, stashes them in short-lived cookies for {@link handleCallbackRoute}
118
+ * to consume, and redirects the user-agent to the provider's authorization
119
+ * endpoint.
120
+ *
121
+ * @public
122
+ */
123
+ export function handleSignInRoute(options?: HandleSignInOptions) {
124
+ return async ({ request }: { request: Request }): Promise<Response> => {
125
+ return handleSignInInternal(request, options);
126
+ };
127
+ }
128
+
129
+ async function handleSignInInternal(
130
+ request: Request,
131
+ options?: HandleSignInOptions,
132
+ ): Promise<Response> {
133
+ const url = new URL(request.url);
134
+
135
+ // These must be unique per authorization request and tied to the user-agent.
136
+ const codeVerifier = client.randomPKCECodeVerifier();
137
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
138
+ const state = client.randomState();
139
+
140
+ const redirectUri = new URL(options?.redirectUri ?? DEFAULT_CALLBACK_PATH, url.origin).toString();
141
+
142
+ let authorizationUrl: URL;
143
+ try {
144
+ const config = await getConfig();
145
+ authorizationUrl = client.buildAuthorizationUrl(config, {
146
+ redirect_uri: redirectUri,
147
+ scope: options?.scope ?? DEFAULT_SCOPE,
148
+ state,
149
+ code_challenge: codeChallenge,
150
+ code_challenge_method: "S256",
151
+ });
152
+ } catch {
153
+ return new Response(JSON.stringify({ error: "Failed to start sign-in" }), {
154
+ status: 500,
155
+ headers: { "Content-Type": "application/json" },
156
+ });
157
+ }
158
+
159
+ const headers = new Headers({ Location: authorizationUrl.toString() });
160
+
161
+ // Stash the PKCE verifier and state for the callback. SameSite=Lax so they
162
+ // survive the top-level redirect back from the provider.
163
+ const cookieOptions = {
164
+ httpOnly: true,
165
+ secure: url.protocol === "https:",
166
+ sameSite: "Lax" as const,
167
+ path: "/",
168
+ maxAge: SIGN_IN_COOKIE_MAX_AGE,
169
+ };
170
+ headers.append("Set-Cookie", serializeCookie(PKCE_VERIFIER_COOKIE, codeVerifier, cookieOptions));
171
+ headers.append("Set-Cookie", serializeCookie(STATE_COOKIE, state, cookieOptions));
172
+
173
+ return new Response(null, { status: 302, headers });
174
+ }
175
+
176
+ /**
177
+ * Build a TanStack route handler for the OAuth/OIDC callback.
178
+ *
179
+ * The handler completes the authorization-code grant using the PKCE
180
+ * `code_verifier` and `state` stashed in cookies during sign-in, invokes
181
+ * {@link HandleCallbackOptions.onSuccess} with the token response, stores the
182
+ * resulting session token in an HttpOnly cookie, clears the one-time sign-in
183
+ * cookies, and redirects the user to {@link HandleCallbackOptions.returnPathname}.
184
+ *
185
+ * @public
186
+ */
187
+ export function handleCallbackRoute(options?: HandleCallbackOptions) {
188
+ return async ({ request }: { request: Request }): Promise<Response> => {
189
+ return handleCallbackInternal(request, options);
190
+ };
191
+ }
192
+
193
+ async function handleCallbackInternal(
194
+ request: Request,
195
+ options?: HandleCallbackOptions,
196
+ ): Promise<Response> {
197
+ const url = new URL(request.url);
198
+ const code = url.searchParams.get("code");
199
+
200
+ if (!code) {
201
+ return handleError(request, 400, "Missing code parameter", undefined, options);
202
+ }
203
+
204
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
205
+
206
+ const pkceCodeVerifier = cookies[PKCE_VERIFIER_COOKIE];
207
+ if (!pkceCodeVerifier) {
208
+ return handleError(request, 400, "Missing PKCE verifier", undefined, options);
209
+ }
210
+
211
+ // Sign-in always sends `state` and stores the matching cookie, so the callback
212
+ // must always prove it belongs to that request. Require the stored value and
213
+ // pass it as `expectedState` unconditionally — `authorizationCodeGrant` then
214
+ // rejects a response whose `state` is missing or mismatched (CSRF defense).
215
+ const expectedState = cookies[STATE_COOKIE];
216
+ if (!expectedState) {
217
+ return handleError(request, 400, "Missing state cookie", undefined, options);
218
+ }
219
+ const checks: client.AuthorizationCodeGrantChecks = { pkceCodeVerifier, expectedState };
220
+
221
+ let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
222
+ try {
223
+ const config = await getConfig();
224
+ tokens = await client.authorizationCodeGrant(config, url, checks);
225
+ } catch (error) {
226
+ return handleError(request, 500, "Token exchange failed", error, options);
227
+ }
228
+
229
+ // `access_token` is always present in a successful token response, so the
230
+ // session token below is guaranteed to be a string.
231
+ const data: HandleAuthSuccessData = {
232
+ accessToken: tokens.access_token,
233
+ idToken: tokens.id_token,
234
+ refreshToken: tokens.refresh_token,
235
+ expiresIn: tokens.expires_in,
236
+ scope: tokens.scope,
237
+ claims: tokens.claims(),
238
+ state: url.searchParams.get("state") ?? undefined,
239
+ };
240
+
241
+ await options?.onSuccess?.(data);
242
+
243
+ const secure = url.protocol === "https:";
244
+ const headers = new Headers({ Location: options?.returnPathname ?? DEFAULT_REDIRECT });
245
+
246
+ // Persist the session token (prefer the ID token) for subsequent requests.
247
+ headers.append(
248
+ "Set-Cookie",
249
+ serializeCookie(AUTH_COOKIE, data.idToken ?? data.accessToken, {
250
+ httpOnly: true,
251
+ secure,
252
+ sameSite: "Lax",
253
+ path: "/",
254
+ maxAge: data.expiresIn,
255
+ }),
256
+ );
257
+
258
+ // Clear the one-time sign-in cookies now that the flow is complete.
259
+ deleteSignInCookies(headers);
260
+
261
+ return new Response(null, { status: 302, headers });
262
+ }
@@ -0,0 +1,82 @@
1
+ import type { IDToken } from "openid-client";
2
+
3
+ export interface HandleSignInOptions {
4
+ /**
5
+ * Callback URL the provider redirects back to after authentication. Accepts
6
+ * an absolute URL (`https://app.example.com/api/auth/callback`) or a path
7
+ * (`/api/auth/callback`); a path resolves against the request origin.
8
+ *
9
+ * This must match both the `redirect_uri` registered with the provider and
10
+ * the route where {@link HandleCallbackOptions} is mounted. Defaults to
11
+ * `/api/auth/callback`.
12
+ */
13
+ redirectUri?: string;
14
+ /**
15
+ * Space-delimited OAuth scopes to request. `openid` is required for an ID
16
+ * token to be returned. Defaults to `openid profile email`.
17
+ */
18
+ scope?: string;
19
+ }
20
+
21
+ export interface HandleCallbackOptions {
22
+ returnPathname?: string;
23
+ onSuccess?: (data: HandleAuthSuccessData) => void | Promise<void>;
24
+ /**
25
+ * Custom error handler. Receives the underlying error and the original
26
+ * request, returns a Response. Errors thrown from inside `onError` are
27
+ * NOT caught by the SDK — they propagate up to the runtime. Wrap your
28
+ * `onError` body in a try/catch if you want different behavior.
29
+ *
30
+ * If both `onError` and `errorRedirectUrl` are provided, `onError` wins
31
+ * and `errorRedirectUrl` is ignored.
32
+ */
33
+ onError?: (params: { error?: unknown; request: Request }) => Response | Promise<Response>;
34
+ /**
35
+ * Optional URL to redirect the user to when the callback fails. Accepts
36
+ * absolute URLs (`https://example.com/sign-in`) or relative paths
37
+ * (`/sign-in?error=auth_failed`); relative values resolve against the
38
+ * request origin.
39
+ *
40
+ * When set and `onError` is not, the SDK responds with a 302 Location
41
+ * redirect plus the verifier-delete cookies. When `onError` is also
42
+ * set, this option is ignored.
43
+ *
44
+ * The redirect URL is set at route-construction time by application
45
+ * code, not derived from request input. Do not pass user-controlled
46
+ * values here. The SDK does not validate the URL scheme; any value the
47
+ * URL constructor accepts is accepted (including `javascript:` and
48
+ * `data:`).
49
+ *
50
+ * If the value is malformed and the URL constructor throws, the SDK
51
+ * logs a config warning and falls back to the path-dependent JSON
52
+ * error response (400 or 500) with delete-cookies.
53
+ */
54
+ errorRedirectUrl?: string;
55
+ }
56
+
57
+ /**
58
+ * Data passed to {@link HandleCallbackOptions.onSuccess} after a successful
59
+ * authorization-code exchange. The shape mirrors what the token endpoint
60
+ * actually returns (an OAuth 2.0 / OIDC token response) — only `accessToken`
61
+ * is guaranteed; everything else depends on the provider, the requested
62
+ * scopes, and the client configuration.
63
+ */
64
+ export interface HandleAuthSuccessData {
65
+ /** OAuth 2.0 access token. Always present. */
66
+ accessToken: string;
67
+ /** OIDC ID token (a JWT), when the provider returns one. */
68
+ idToken?: string;
69
+ /** Refresh token, when the provider issues one (e.g. with `offline_access`). */
70
+ refreshToken?: string;
71
+ /** Seconds until {@link accessToken} expires, when the provider reports it. */
72
+ expiresIn?: number;
73
+ /** Space-delimited scopes granted, when the provider echoes them back. */
74
+ scope?: string;
75
+ /**
76
+ * Parsed claims of the ID token — the authenticated user's identity (`sub`,
77
+ * `email`, any custom claims). Present only when an ID token was returned.
78
+ */
79
+ claims?: IDToken;
80
+ /** The `state` value echoed back by the provider, when present. */
81
+ state?: string;
82
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type User = {};
2
+ export type Impersonator = {};
3
+ export type Session = {};
4
+ export type AuthResult = {};
5
+ export type BaseTokenClaims = {};
6
+ export type CustomClaims = {};
package/tsconfig.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Language and environment
4
+ "target": "ES2022",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "types": ["node"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+
10
+ // Emit
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "outDir": "dist",
15
+ "removeComments": true,
16
+ "importHelpers": true,
17
+
18
+ // Interop constraints
19
+ "isolatedModules": true,
20
+ "allowSyntheticDefaultImports": true,
21
+ "esModuleInterop": true,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "verbatimModuleSyntax": true,
24
+
25
+ // Type checking
26
+ "strict": true,
27
+ "noUnusedLocals": true,
28
+ "noUnusedParameters": true,
29
+ "noImplicitReturns": true,
30
+ "noFallthroughCasesInSwitch": true,
31
+ "noUncheckedIndexedAccess": true,
32
+ "noImplicitOverride": true,
33
+
34
+ // JSX (for React peer dependency)
35
+ "jsx": "react-jsx",
36
+
37
+ // Skip type checking of declaration files
38
+ "skipLibCheck": true
39
+ },
40
+ "include": ["src/**/*"],
41
+ "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
42
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig((options) => [
4
+ {
5
+ ...options,
6
+ entry: ["src/index.ts"],
7
+ dts: true,
8
+ sourcemap: true,
9
+ exports: true,
10
+ ignoreWatch: [".turbo"],
11
+ },
12
+ ]);