@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 +21 -0
- package/dist/index.d.mts +48 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +252 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +44 -0
- package/src/index.ts +15 -0
- package/src/server/cookie-utils.test.ts +83 -0
- package/src/server/cookie-utils.ts +56 -0
- package/src/server/server.ts +262 -0
- package/src/server/types.ts +82 -0
- package/src/types.ts +6 -0
- package/tsconfig.json +42 -0
- package/tsdown.config.ts +12 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
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
|
+
}
|