@douvery/auth 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +116 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/qwik/index.d.ts +42 -3
- package/dist/qwik/index.js +31 -2
- package/dist/qwik/index.js.map +1 -1
- package/dist/session/index.d.ts +205 -0
- package/dist/session/index.js +311 -0
- package/dist/session/index.js.map +1 -0
- package/package.json +17 -11
- package/src/qwik/index.tsx +21 -1
- package/src/qwik/session.ts +72 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/session/utils.ts
|
|
9
|
+
var DEFAULT_FALLBACK_CACHE_TTL_MS = 3e4;
|
|
10
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 3e3;
|
|
11
|
+
function computeHmac(message, secret) {
|
|
12
|
+
const { createHmac } = __require("crypto");
|
|
13
|
+
return createHmac("sha256", secret).update(message).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
function parseJwtExp(token) {
|
|
16
|
+
try {
|
|
17
|
+
const parts = token.split(".");
|
|
18
|
+
if (parts.length !== 3) return void 0;
|
|
19
|
+
const payload = JSON.parse(
|
|
20
|
+
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))
|
|
21
|
+
);
|
|
22
|
+
return typeof payload.exp === "number" ? payload.exp : void 0;
|
|
23
|
+
} catch {
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function computeCacheTTL(jwtExp, serverExpiresIn, fallbackTtlMs = DEFAULT_FALLBACK_CACHE_TTL_MS) {
|
|
28
|
+
if (serverExpiresIn && serverExpiresIn > 0) {
|
|
29
|
+
return Math.min(Math.max(serverExpiresIn * 0.9 * 1e3, 5e3), 3e5);
|
|
30
|
+
}
|
|
31
|
+
if (jwtExp) {
|
|
32
|
+
const remainingMs = jwtExp * 1e3 - Date.now();
|
|
33
|
+
if (remainingMs > 0) {
|
|
34
|
+
return Math.min(Math.max(remainingMs * 0.9, 5e3), 3e5);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return fallbackTtlMs;
|
|
38
|
+
}
|
|
39
|
+
async function fetchWithTimeout(url, options, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timeoutId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/session/resolver.ts
|
|
50
|
+
var DEFAULTS = {
|
|
51
|
+
cookieMaxAge: 30 * 24 * 60 * 60,
|
|
52
|
+
// 30 days in seconds
|
|
53
|
+
secureCookies: true,
|
|
54
|
+
fetchTimeoutMs: 3e3,
|
|
55
|
+
refreshTimeoutMs: 8e3,
|
|
56
|
+
fallbackCacheTtlMs: 3e4,
|
|
57
|
+
debug: false
|
|
58
|
+
};
|
|
59
|
+
var NOOP_LOGGER = {
|
|
60
|
+
debug() {
|
|
61
|
+
},
|
|
62
|
+
warn() {
|
|
63
|
+
},
|
|
64
|
+
error() {
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var CONSOLE_LOGGER = {
|
|
68
|
+
debug: (...args) => console.log("[Session]", ...args),
|
|
69
|
+
warn: (...args) => console.warn("[Session]", ...args),
|
|
70
|
+
error: (...args) => console.error("[Session]", ...args)
|
|
71
|
+
};
|
|
72
|
+
function createSessionResolver(config) {
|
|
73
|
+
const cfg = { ...DEFAULTS, ...config };
|
|
74
|
+
const log = cfg.logger ?? (cfg.debug ? CONSOLE_LOGGER : NOOP_LOGGER);
|
|
75
|
+
if (!cfg.sessionApiUrl) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"[Session] sessionApiUrl is required. Provide the auth server session API URL."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (!cfg.cookieName) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"[Session] cookieName is required. Provide the cookie name for the session ID."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const tokenCache = /* @__PURE__ */ new WeakMap();
|
|
86
|
+
const pendingResolutions = /* @__PURE__ */ new WeakMap();
|
|
87
|
+
const pendingRefreshBySession = /* @__PURE__ */ new Map();
|
|
88
|
+
function getSessionId(cookies) {
|
|
89
|
+
return cookies.get(cfg.cookieName);
|
|
90
|
+
}
|
|
91
|
+
function hasSession(cookies) {
|
|
92
|
+
return !!getSessionId(cookies);
|
|
93
|
+
}
|
|
94
|
+
function setSessionCookie(sessionId, cookies) {
|
|
95
|
+
cookies.set(cfg.cookieName, sessionId, {
|
|
96
|
+
path: "/",
|
|
97
|
+
httpOnly: true,
|
|
98
|
+
secure: cfg.secureCookies,
|
|
99
|
+
sameSite: "lax",
|
|
100
|
+
maxAge: cfg.cookieMaxAge
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function clearSessionCookie(cookies) {
|
|
104
|
+
cookies.set(cfg.cookieName, "", {
|
|
105
|
+
path: "/",
|
|
106
|
+
httpOnly: true,
|
|
107
|
+
secure: cfg.secureCookies,
|
|
108
|
+
sameSite: "lax",
|
|
109
|
+
maxAge: 0
|
|
110
|
+
});
|
|
111
|
+
tokenCache.delete(cookies);
|
|
112
|
+
}
|
|
113
|
+
function buildHeaders() {
|
|
114
|
+
const headers = {
|
|
115
|
+
"Content-Type": "application/json"
|
|
116
|
+
};
|
|
117
|
+
if (cfg.internalServiceName && cfg.internalServiceSecret) {
|
|
118
|
+
const timestamp = String(Date.now());
|
|
119
|
+
const message = `${timestamp}:${cfg.internalServiceName}`;
|
|
120
|
+
headers["X-Douvery-Internal-Service"] = cfg.internalServiceName;
|
|
121
|
+
headers["X-Douvery-Internal-Timestamp"] = timestamp;
|
|
122
|
+
headers["X-Douvery-Internal-Signature"] = computeHmac(
|
|
123
|
+
message,
|
|
124
|
+
cfg.internalServiceSecret
|
|
125
|
+
);
|
|
126
|
+
} else if (cfg.clientId && cfg.clientSecret) {
|
|
127
|
+
headers["X-Client-Id"] = cfg.clientId;
|
|
128
|
+
headers["X-Client-Secret"] = cfg.clientSecret;
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
async function resolveSessionToken(sessionId, cookies) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetchWithTimeout(
|
|
135
|
+
`${cfg.sessionApiUrl}/token`,
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: buildHeaders(),
|
|
139
|
+
body: JSON.stringify({ session_id: sessionId })
|
|
140
|
+
},
|
|
141
|
+
cfg.fetchTimeoutMs
|
|
142
|
+
);
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
if (response.status === 401 || response.status === 404) {
|
|
145
|
+
log.warn("Session expired or not found:", response.status);
|
|
146
|
+
clearSessionCookie(cookies);
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
const errorText = await response.text().catch(() => "");
|
|
150
|
+
log.error("Token resolution failed:", response.status, errorText);
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
const data = await response.json();
|
|
154
|
+
if (data.access_token) {
|
|
155
|
+
const jwtExp = parseJwtExp(data.access_token);
|
|
156
|
+
const ttl = computeCacheTTL(
|
|
157
|
+
jwtExp,
|
|
158
|
+
data.expires_in,
|
|
159
|
+
cfg.fallbackCacheTtlMs
|
|
160
|
+
);
|
|
161
|
+
tokenCache.set(cookies, {
|
|
162
|
+
token: data.access_token,
|
|
163
|
+
expiresAt: Date.now() + ttl,
|
|
164
|
+
jwtExp
|
|
165
|
+
});
|
|
166
|
+
return data.access_token;
|
|
167
|
+
}
|
|
168
|
+
return void 0;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
171
|
+
log.error("Token resolution timed out after", cfg.fetchTimeoutMs, "ms");
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
log.error("Network error resolving token:", error);
|
|
175
|
+
return void 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function getAccessToken(cookies) {
|
|
179
|
+
const cached = tokenCache.get(cookies);
|
|
180
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
181
|
+
return cached.token;
|
|
182
|
+
}
|
|
183
|
+
const pending = pendingResolutions.get(cookies);
|
|
184
|
+
if (pending) {
|
|
185
|
+
return pending;
|
|
186
|
+
}
|
|
187
|
+
const sessionId = getSessionId(cookies);
|
|
188
|
+
if (!sessionId) {
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
const resolution = resolveSessionToken(sessionId, cookies);
|
|
192
|
+
pendingResolutions.set(cookies, resolution);
|
|
193
|
+
try {
|
|
194
|
+
const token = await resolution;
|
|
195
|
+
if (!token) {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
if (cfg.debug) {
|
|
199
|
+
const exp = parseJwtExp(token);
|
|
200
|
+
if (exp && exp * 1e3 <= Date.now()) {
|
|
201
|
+
log.debug("Resolved JWT is expired \u2014 caller should handle refresh");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return token;
|
|
205
|
+
} finally {
|
|
206
|
+
pendingResolutions.delete(cookies);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function getAccessTokenSync(cookies) {
|
|
210
|
+
const cached = tokenCache.get(cookies);
|
|
211
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
212
|
+
return cached.token;
|
|
213
|
+
}
|
|
214
|
+
return void 0;
|
|
215
|
+
}
|
|
216
|
+
async function _doRefreshSession(cookies, sessionId) {
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetchWithTimeout(
|
|
219
|
+
`${cfg.sessionApiUrl}/refresh`,
|
|
220
|
+
{
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: buildHeaders(),
|
|
223
|
+
body: JSON.stringify({ session_id: sessionId })
|
|
224
|
+
},
|
|
225
|
+
cfg.refreshTimeoutMs
|
|
226
|
+
);
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
if (response.status === 401 || response.status === 404) {
|
|
229
|
+
log.warn("Session expired during refresh:", response.status);
|
|
230
|
+
clearSessionCookie(cookies);
|
|
231
|
+
return "definitive_failure";
|
|
232
|
+
}
|
|
233
|
+
if (cfg.debug) {
|
|
234
|
+
const errorText = await response.text().catch(() => "");
|
|
235
|
+
log.warn("Refresh returned", response.status, errorText);
|
|
236
|
+
}
|
|
237
|
+
return "transient_failure";
|
|
238
|
+
}
|
|
239
|
+
tokenCache.delete(cookies);
|
|
240
|
+
log.debug("Tokens refreshed successfully via session");
|
|
241
|
+
return "success";
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
244
|
+
log.error(
|
|
245
|
+
"Refresh request timed out after",
|
|
246
|
+
cfg.refreshTimeoutMs,
|
|
247
|
+
"ms"
|
|
248
|
+
);
|
|
249
|
+
return "transient_failure";
|
|
250
|
+
}
|
|
251
|
+
log.error("Error refreshing session:", error);
|
|
252
|
+
return "transient_failure";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function refreshSession(cookies) {
|
|
256
|
+
const sessionId = getSessionId(cookies);
|
|
257
|
+
if (!sessionId) {
|
|
258
|
+
return "definitive_failure";
|
|
259
|
+
}
|
|
260
|
+
const pendingRefresh = pendingRefreshBySession.get(sessionId);
|
|
261
|
+
if (pendingRefresh) {
|
|
262
|
+
log.debug("Refresh already in progress for session, joining...");
|
|
263
|
+
const result = await pendingRefresh;
|
|
264
|
+
if (result === "success") {
|
|
265
|
+
tokenCache.delete(cookies);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
const refreshPromise = _doRefreshSession(cookies, sessionId);
|
|
270
|
+
pendingRefreshBySession.set(sessionId, refreshPromise);
|
|
271
|
+
try {
|
|
272
|
+
return await refreshPromise;
|
|
273
|
+
} finally {
|
|
274
|
+
pendingRefreshBySession.delete(sessionId);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function destroySession(cookies) {
|
|
278
|
+
const sessionId = getSessionId(cookies);
|
|
279
|
+
if (sessionId) {
|
|
280
|
+
try {
|
|
281
|
+
await fetchWithTimeout(
|
|
282
|
+
`${cfg.sessionApiUrl}/destroy`,
|
|
283
|
+
{
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: buildHeaders(),
|
|
286
|
+
body: JSON.stringify({ session_id: sessionId })
|
|
287
|
+
},
|
|
288
|
+
cfg.fetchTimeoutMs
|
|
289
|
+
);
|
|
290
|
+
log.debug("Session destroyed on auth server");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log.error("Error destroying session:", error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
clearSessionCookie(cookies);
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
getAccessToken,
|
|
299
|
+
getAccessTokenSync,
|
|
300
|
+
refreshSession,
|
|
301
|
+
destroySession,
|
|
302
|
+
setSessionCookie,
|
|
303
|
+
getSessionId,
|
|
304
|
+
hasSession,
|
|
305
|
+
clearSessionCookie
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export { computeCacheTTL, createSessionResolver, fetchWithTimeout, parseJwtExp };
|
|
310
|
+
//# sourceMappingURL=index.js.map
|
|
311
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/session/utils.ts","../../src/session/resolver.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @douvery/auth - Session Utilities\n * Pure functions for JWT parsing, cache TTL computation, and fetch timeout\n */\n\n/** Default fallback cache TTL: 30 seconds */\nconst DEFAULT_FALLBACK_CACHE_TTL_MS = 30_000;\n\n/** Default fetch timeout: 3 seconds */\nconst DEFAULT_FETCH_TIMEOUT_MS = 3_000;\n\n/**\n * Compute HMAC-SHA256 hex digest (synchronous, Node.js crypto).\n * Used for internal service authentication headers.\n *\n * Note: Uses dynamic require to avoid bundler issues. This function\n * is only called in SSR context where Node.js crypto is available.\n */\nexport function computeHmac(message: string, secret: string): string {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const { createHmac } = require(\"crypto\");\n return createHmac(\"sha256\", secret).update(message).digest(\"hex\");\n}\n\n/**\n * Extract `exp` claim from a JWT without full verification.\n * Returns the exp as Unix seconds, or undefined if parsing fails.\n *\n * This is a lightweight alternative to full JWT decoding when you\n * only need the expiration timestamp.\n */\nexport function parseJwtExp(token: string): number | undefined {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return undefined;\n\n const payload = JSON.parse(\n atob(parts[1].replace(/-/g, \"+\").replace(/_/g, \"/\")),\n );\n return typeof payload.exp === \"number\" ? payload.exp : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Calculate cache TTL based on the JWT's `exp` claim.\n *\n * Strategy:\n * 1. If auth server provides `expires_in`, use 90% of that\n * 2. If JWT has `exp`, use 90% of remaining lifetime\n * 3. Fall back to `fallbackTtlMs`\n *\n * Result is clamped between 5s and 5min to prevent both\n * excessive polling and stale caches.\n */\nexport function computeCacheTTL(\n jwtExp: number | undefined,\n serverExpiresIn?: number,\n fallbackTtlMs: number = DEFAULT_FALLBACK_CACHE_TTL_MS,\n): number {\n if (serverExpiresIn && serverExpiresIn > 0) {\n // Auth server told us exactly how long the token lives (in seconds)\n return Math.min(Math.max(serverExpiresIn * 0.9 * 1000, 5_000), 300_000);\n }\n\n if (jwtExp) {\n const remainingMs = jwtExp * 1000 - Date.now();\n if (remainingMs > 0) {\n return Math.min(Math.max(remainingMs * 0.9, 5_000), 300_000);\n }\n }\n\n return fallbackTtlMs;\n}\n\n/**\n * Fetch wrapper with AbortController timeout.\n * Prevents SSR from hanging if the auth server is unresponsive.\n *\n * @param url - The URL to fetch\n * @param options - Standard fetch RequestInit options\n * @param timeoutMs - Timeout in milliseconds (default: 3000)\n * @throws DOMException with name 'AbortError' on timeout\n */\nexport async function fetchWithTimeout(\n url: string,\n options: RequestInit,\n timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,\n): Promise<Response> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n return await fetch(url, { ...options, signal: controller.signal });\n } finally {\n clearTimeout(timeoutId);\n }\n}\n","/**\n * @douvery/auth - Session Resolver\n * Factory for creating framework-agnostic opaque session resolvers.\n *\n * Architecture:\n * ┌─────────┐ session_id cookie ┌──────────────┐ JWT ┌──────────────┐\n * │ Browser │ ──────────────────▶ │ SSR App │ ─────────▶ │ Resource │\n * │ │ │ │ │ Server │\n * └─────────┘ └──────┬───────┘ │ (GraphQL) │\n * │ └──────────────┘\n * │ POST /api/session/token\n * ▼\n * ┌──────────────┐\n * │ Auth Server │\n * │ (Redis) │\n * └──────────────┘\n *\n * Security:\n * - Browser only sees opaque session_id (256-bit random, base64url)\n * - JWT access_token lives only in Redis + server memory\n * - IdP UI auth: X-Douvery-Internal-Service + HMAC signature (internal trust)\n * - Consumer app auth: X-Client-Id + X-Client-Secret headers (OAuth per-client)\n * - Per-request WeakMap cache prevents duplicate network calls\n * - Cross-request Map<string> dedup prevents refresh token reuse detection\n */\n\nimport type {\n SessionResolverConfig,\n CookieAdapter,\n RefreshResult,\n SessionLogger,\n SessionResolver,\n} from \"./types\";\nimport {\n parseJwtExp,\n computeCacheTTL,\n fetchWithTimeout,\n computeHmac,\n} from \"./utils\";\n\n// ============================================================================\n// Defaults & internal helpers\n// ============================================================================\n\nconst DEFAULTS = {\n cookieMaxAge: 30 * 24 * 60 * 60, // 30 days in seconds\n secureCookies: true,\n fetchTimeoutMs: 3_000,\n refreshTimeoutMs: 8_000,\n fallbackCacheTtlMs: 30_000,\n debug: false,\n} as const;\n\n/** Noop logger for when debug is off */\nconst NOOP_LOGGER: SessionLogger = {\n debug() {},\n warn() {},\n error() {},\n};\n\n/** Console-based logger with [Session] prefix */\nconst CONSOLE_LOGGER: SessionLogger = {\n debug: (...args) => console.log(\"[Session]\", ...args),\n warn: (...args) => console.warn(\"[Session]\", ...args),\n error: (...args) => console.error(\"[Session]\", ...args),\n};\n\n/** Token cache entry */\ninterface CacheEntry {\n token: string;\n expiresAt: number;\n jwtExp?: number;\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\n/**\n * Create a session resolver instance.\n *\n * Returns a {@link SessionResolver} with internal state (caches, dedup maps)\n * scoped to this resolver instance. Multiple resolvers can coexist\n * (e.g., for different auth servers or cookie names).\n *\n * @example\n * ```typescript\n * // IdP UI (like accounts.google.com — internal service, NOT an OAuth client)\n * const resolver = createSessionResolver({\n * sessionApiUrl: 'http://localhost:9924/api/session',\n * internalServiceName: 'auth-web',\n * internalServiceSecret: process.env.INTERNAL_SERVICE_SECRET,\n * cookieName: 'my-session',\n * debug: process.env.NODE_ENV === 'development',\n * });\n *\n * // Consumer app (OAuth client — douvery-web, center, mobile)\n * const resolver = createSessionResolver({\n * sessionApiUrl: 'http://localhost:9924/api/session',\n * clientId: process.env.OAUTH_CLIENT_ID,\n * clientSecret: process.env.OAUTH_CLIENT_SECRET,\n * cookieName: 'my-session',\n * });\n *\n * // In a request handler:\n * const token = await resolver.getAccessToken(cookieAdapter);\n * ```\n */\nexport function createSessionResolver(\n config: SessionResolverConfig,\n): SessionResolver {\n // Merge defaults\n const cfg = { ...DEFAULTS, ...config };\n\n // Configure logger\n const log: SessionLogger =\n cfg.logger ?? (cfg.debug ? CONSOLE_LOGGER : NOOP_LOGGER);\n\n // Validate required config\n if (!cfg.sessionApiUrl) {\n throw new Error(\n \"[Session] sessionApiUrl is required. \" +\n \"Provide the auth server session API URL.\",\n );\n }\n\n if (!cfg.cookieName) {\n throw new Error(\n \"[Session] cookieName is required. \" +\n \"Provide the cookie name for the session ID.\",\n );\n }\n\n // ============================================================================\n // Internal state (scoped to this resolver instance)\n // ============================================================================\n\n /**\n * Per-request token cache. WeakMap keyed by CookieAdapter object reference.\n * Each SSR request should reuse the SAME adapter instance for all calls,\n * ensuring per-request caching without cross-request pollution.\n */\n const tokenCache = new WeakMap<CookieAdapter, CacheEntry>();\n\n /**\n * Pending resolution dedup. Prevents duplicate POST /token calls when\n * multiple routeLoaders in the same SSR request call getAccessToken().\n */\n const pendingResolutions = new WeakMap<\n CookieAdapter,\n Promise<string | undefined>\n >();\n\n /**\n * Cross-request refresh dedup keyed by session_id string.\n *\n * CRITICAL: Must be keyed by sessionId, NOT by CookieAdapter.\n * Concurrent SSR requests (e.g., SPA hover prefetch) get DIFFERENT\n * adapter objects even though they share the same session_id. Without\n * string-based dedup, concurrent refreshes would trigger refresh token\n * reuse detection and destroy the session.\n *\n * Cleanup: entries are deleted in the finally block of refreshSession().\n */\n const pendingRefreshBySession = new Map<string, Promise<RefreshResult>>();\n\n // ============================================================================\n // Cookie helpers\n // ============================================================================\n\n function getSessionId(cookies: CookieAdapter): string | undefined {\n return cookies.get(cfg.cookieName);\n }\n\n function hasSession(cookies: CookieAdapter): boolean {\n return !!getSessionId(cookies);\n }\n\n function setSessionCookie(sessionId: string, cookies: CookieAdapter): void {\n cookies.set(cfg.cookieName, sessionId, {\n path: \"/\",\n httpOnly: true,\n secure: cfg.secureCookies,\n sameSite: \"lax\",\n maxAge: cfg.cookieMaxAge,\n });\n }\n\n function clearSessionCookie(cookies: CookieAdapter): void {\n cookies.set(cfg.cookieName, \"\", {\n path: \"/\",\n httpOnly: true,\n secure: cfg.secureCookies,\n sameSite: \"lax\",\n maxAge: 0,\n });\n tokenCache.delete(cookies);\n }\n\n // ============================================================================\n // Internal: build headers for auth server requests\n // ============================================================================\n\n function buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n };\n\n // Method 1: Internal service HMAC (IdP UI)\n if (cfg.internalServiceName && cfg.internalServiceSecret) {\n const timestamp = String(Date.now());\n const message = `${timestamp}:${cfg.internalServiceName}`;\n headers[\"X-Douvery-Internal-Service\"] = cfg.internalServiceName;\n headers[\"X-Douvery-Internal-Timestamp\"] = timestamp;\n headers[\"X-Douvery-Internal-Signature\"] = computeHmac(\n message,\n cfg.internalServiceSecret,\n );\n }\n // Method 2: Per-client OAuth credentials (consumer apps)\n else if (cfg.clientId && cfg.clientSecret) {\n headers[\"X-Client-Id\"] = cfg.clientId;\n headers[\"X-Client-Secret\"] = cfg.clientSecret;\n }\n\n return headers;\n }\n\n // ============================================================================\n // Internal: network resolution (session_id -> JWT)\n // ============================================================================\n\n async function resolveSessionToken(\n sessionId: string,\n cookies: CookieAdapter,\n ): Promise<string | undefined> {\n try {\n const response = await fetchWithTimeout(\n `${cfg.sessionApiUrl}/token`,\n {\n method: \"POST\",\n headers: buildHeaders(),\n body: JSON.stringify({ session_id: sessionId }),\n },\n cfg.fetchTimeoutMs,\n );\n\n if (!response.ok) {\n if (response.status === 401 || response.status === 404) {\n log.warn(\"Session expired or not found:\", response.status);\n clearSessionCookie(cookies);\n return undefined;\n }\n\n const errorText = await response.text().catch(() => \"\");\n log.error(\"Token resolution failed:\", response.status, errorText);\n return undefined;\n }\n\n const data = await response.json();\n\n if (data.access_token) {\n const jwtExp = parseJwtExp(data.access_token);\n const ttl = computeCacheTTL(\n jwtExp,\n data.expires_in,\n cfg.fallbackCacheTtlMs,\n );\n\n tokenCache.set(cookies, {\n token: data.access_token,\n expiresAt: Date.now() + ttl,\n jwtExp,\n });\n\n return data.access_token;\n }\n\n return undefined;\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n log.error(\"Token resolution timed out after\", cfg.fetchTimeoutMs, \"ms\");\n return undefined;\n }\n log.error(\"Network error resolving token:\", error);\n return undefined;\n }\n }\n\n // ============================================================================\n // Token resolution (public)\n // ============================================================================\n\n /**\n * Resolve opaque session to JWT access_token (ASYNC).\n *\n * Uses per-request caching via WeakMap to avoid duplicate network calls.\n *\n * IMPORTANT: Returns the resolved JWT even if it's expired. The caller\n * (e.g., validateAndRefreshTokens) is responsible for checking expiry\n * and triggering refreshSession(). This avoids a race condition where\n * both this function and the caller would attempt concurrent refreshes.\n */\n async function getAccessToken(\n cookies: CookieAdapter,\n ): Promise<string | undefined> {\n // 1. Check resolved cache\n const cached = tokenCache.get(cookies);\n if (cached && cached.expiresAt > Date.now()) {\n return cached.token;\n }\n\n // 2. Dedup concurrent calls for the same request\n const pending = pendingResolutions.get(cookies);\n if (pending) {\n return pending;\n }\n\n // 3. Check for session cookie\n const sessionId = getSessionId(cookies);\n if (!sessionId) {\n return undefined;\n }\n\n // 4. Start async resolution\n const resolution = resolveSessionToken(sessionId, cookies);\n pendingResolutions.set(cookies, resolution);\n\n try {\n const token = await resolution;\n\n if (!token) {\n return undefined;\n }\n\n // Return even if expired — caller handles refresh\n if (cfg.debug) {\n const exp = parseJwtExp(token);\n if (exp && exp * 1000 <= Date.now()) {\n log.debug(\"Resolved JWT is expired — caller should handle refresh\");\n }\n }\n\n return token;\n } finally {\n pendingResolutions.delete(cookies);\n }\n }\n\n /**\n * Synchronous access to cached token (for sync header builders).\n * Returns cached value only — NO network call.\n */\n function getAccessTokenSync(cookies: CookieAdapter): string | undefined {\n const cached = tokenCache.get(cookies);\n if (cached && cached.expiresAt > Date.now()) {\n return cached.token;\n }\n return undefined;\n }\n\n // ============================================================================\n // Session lifecycle\n // ============================================================================\n\n /** Internal: actual refresh logic, called only once per session via dedup. */\n async function _doRefreshSession(\n cookies: CookieAdapter,\n sessionId: string,\n ): Promise<RefreshResult> {\n try {\n const response = await fetchWithTimeout(\n `${cfg.sessionApiUrl}/refresh`,\n {\n method: \"POST\",\n headers: buildHeaders(),\n body: JSON.stringify({ session_id: sessionId }),\n },\n cfg.refreshTimeoutMs,\n );\n\n if (!response.ok) {\n if (response.status === 401 || response.status === 404) {\n log.warn(\"Session expired during refresh:\", response.status);\n clearSessionCookie(cookies);\n return \"definitive_failure\";\n }\n\n if (cfg.debug) {\n const errorText = await response.text().catch(() => \"\");\n log.warn(\"Refresh returned\", response.status, errorText);\n }\n return \"transient_failure\";\n }\n\n // Invalidate token cache so next getAccessToken() fetches fresh JWT\n tokenCache.delete(cookies);\n log.debug(\"Tokens refreshed successfully via session\");\n\n return \"success\";\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n log.error(\n \"Refresh request timed out after\",\n cfg.refreshTimeoutMs,\n \"ms\",\n );\n return \"transient_failure\";\n }\n log.error(\"Error refreshing session:\", error);\n return \"transient_failure\";\n }\n }\n\n /**\n * Refresh session tokens via auth server.\n * Triggers full token rotation (new access + new refresh token in Redis).\n *\n * Deduplicates concurrent refresh calls across ALL requests for the same\n * session. Multiple concurrent SSR requests (SPA hover/prefetch) share\n * the same session_id but have different CookieAdapter objects. Only one\n * actual POST /refresh fires per session — subsequent requests join the\n * same promise.\n */\n async function refreshSession(\n cookies: CookieAdapter,\n ): Promise<RefreshResult> {\n const sessionId = getSessionId(cookies);\n if (!sessionId) {\n return \"definitive_failure\";\n }\n\n // Dedup concurrent refresh calls across ALL requests for the same session\n const pendingRefresh = pendingRefreshBySession.get(sessionId);\n if (pendingRefresh) {\n log.debug(\"Refresh already in progress for session, joining...\");\n const result = await pendingRefresh;\n\n // Invalidate THIS request's token cache\n // (joining request may have stale cache from before the refresh)\n if (result === \"success\") {\n tokenCache.delete(cookies);\n }\n\n return result;\n }\n\n const refreshPromise = _doRefreshSession(cookies, sessionId);\n pendingRefreshBySession.set(sessionId, refreshPromise);\n\n try {\n return await refreshPromise;\n } finally {\n pendingRefreshBySession.delete(sessionId);\n }\n }\n\n /**\n * Destroy session on auth server and clear local cookie.\n * Used during logout.\n */\n async function destroySession(cookies: CookieAdapter): Promise<void> {\n const sessionId = getSessionId(cookies);\n\n if (sessionId) {\n try {\n await fetchWithTimeout(\n `${cfg.sessionApiUrl}/destroy`,\n {\n method: \"POST\",\n headers: buildHeaders(),\n body: JSON.stringify({ session_id: sessionId }),\n },\n cfg.fetchTimeoutMs,\n );\n log.debug(\"Session destroyed on auth server\");\n } catch (error) {\n log.error(\"Error destroying session:\", error);\n // Continue with local cleanup even if server call fails\n }\n }\n\n clearSessionCookie(cookies);\n }\n\n // ============================================================================\n // Public API\n // ============================================================================\n\n return {\n getAccessToken,\n getAccessTokenSync,\n refreshSession,\n destroySession,\n setSessionCookie,\n getSessionId,\n hasSession,\n clearSessionCookie,\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@douvery/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "OAuth 2.0/OIDC client for Douvery authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"types": "./dist/index.d.ts"
|
|
13
13
|
},
|
|
14
|
+
"./session": {
|
|
15
|
+
"import": "./dist/session/index.js",
|
|
16
|
+
"types": "./dist/session/index.d.ts"
|
|
17
|
+
},
|
|
14
18
|
"./react": {
|
|
15
19
|
"import": "./dist/react/index.js",
|
|
16
20
|
"types": "./dist/react/index.d.ts"
|
|
@@ -25,9 +29,10 @@
|
|
|
25
29
|
"src/qwik"
|
|
26
30
|
],
|
|
27
31
|
"scripts": {
|
|
28
|
-
"build": "npm run clean && tsup",
|
|
29
|
-
"dev": "tsup --watch",
|
|
30
|
-
"clean": "rm -rf dist"
|
|
32
|
+
"build": "npm run clean && node ./node_modules/tsup/dist/cli-default.js",
|
|
33
|
+
"dev": "node ./node_modules/tsup/dist/cli-default.js --watch",
|
|
34
|
+
"clean": "rm -rf dist",
|
|
35
|
+
"deploy:npm": "npm run build && npm publish --access public --tag latest"
|
|
31
36
|
},
|
|
32
37
|
"keywords": [
|
|
33
38
|
"douvery",
|
|
@@ -42,11 +47,11 @@
|
|
|
42
47
|
"license": "MIT",
|
|
43
48
|
"repository": {
|
|
44
49
|
"type": "git",
|
|
45
|
-
"url": "https://github.com/douvery/douvery-auth.git"
|
|
50
|
+
"url": "git+https://github.com/douvery/douvery-auth.git"
|
|
46
51
|
},
|
|
47
52
|
"peerDependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
53
|
+
"@builder.io/qwik": ">=1.0.0",
|
|
54
|
+
"react": ">=18.0.0"
|
|
50
55
|
},
|
|
51
56
|
"peerDependenciesMeta": {
|
|
52
57
|
"react": {
|
|
@@ -57,10 +62,11 @@
|
|
|
57
62
|
}
|
|
58
63
|
},
|
|
59
64
|
"devDependencies": {
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"react": "^18.2.0",
|
|
65
|
+
"@builder.io/qwik": "^1.9.0",
|
|
66
|
+
"@types/node": "^25.2.3",
|
|
63
67
|
"@types/react": "^18.2.0",
|
|
64
|
-
"
|
|
68
|
+
"react": "^18.2.0",
|
|
69
|
+
"tsup": "^8.0.0",
|
|
70
|
+
"typescript": "^5.4.0"
|
|
65
71
|
}
|
|
66
72
|
}
|
package/src/qwik/index.tsx
CHANGED
|
@@ -121,7 +121,22 @@ export const DouveryAuthProvider = component$<DouveryAuthProviderProps>(
|
|
|
121
121
|
// The QRL is invoked here, returning the full config (with customStorage).
|
|
122
122
|
// noSerialize() wraps the client so Qwik doesn't try to serialize it.
|
|
123
123
|
useVisibleTask$(async () => {
|
|
124
|
-
|
|
124
|
+
let config: DouveryAuthConfig | undefined;
|
|
125
|
+
try {
|
|
126
|
+
config = await config$();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!config) {
|
|
133
|
+
error.value = new Error(
|
|
134
|
+
"[DouveryAuthProvider] config$() returned undefined. " +
|
|
135
|
+
"Check that the QRL correctly returns a DouveryAuthConfig object.",
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
const client = createDouveryAuth(config);
|
|
126
141
|
clientRef.value = noSerialize(client);
|
|
127
142
|
|
|
@@ -436,4 +451,9 @@ export type {
|
|
|
436
451
|
AddAccountOptions,
|
|
437
452
|
RevokeTokenOptions,
|
|
438
453
|
AuthUrl,
|
|
454
|
+
CookieAdapter,
|
|
455
|
+
CookieSetOptions,
|
|
439
456
|
} from "@douvery/auth";
|
|
457
|
+
|
|
458
|
+
// Session adapter for Qwik City
|
|
459
|
+
export { createQwikSessionAdapter } from "./session";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @douvery/auth/qwik - Session Adapter
|
|
3
|
+
*
|
|
4
|
+
* Adapts Qwik City's Cookie interface to the generic CookieAdapter
|
|
5
|
+
* used by createSessionResolver().
|
|
6
|
+
*
|
|
7
|
+
* Memoized: returns the same adapter instance for the same Cookie object,
|
|
8
|
+
* ensuring the resolver's per-request WeakMap cache works correctly when
|
|
9
|
+
* multiple routeLoaders call getAccessToken() in the same SSR request.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CookieAdapter, CookieSetOptions } from "@douvery/auth";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Qwik City Cookie-like interface.
|
|
16
|
+
* Duck-typed to avoid hard dependency on @builder.io/qwik-city.
|
|
17
|
+
*/
|
|
18
|
+
interface QwikCookieLike {
|
|
19
|
+
get(name: string): { value: string } | null;
|
|
20
|
+
set(
|
|
21
|
+
name: string,
|
|
22
|
+
value: string | number | Record<string, unknown>,
|
|
23
|
+
options?: Record<string, unknown>,
|
|
24
|
+
): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adapter cache ensures the SAME CookieAdapter instance is returned
|
|
29
|
+
* for the same Qwik Cookie object. This is critical because:
|
|
30
|
+
*
|
|
31
|
+
* 1. The resolver uses WeakMap<CookieAdapter> for per-request caching
|
|
32
|
+
* 2. Multiple routeLoaders in the same SSR request share the same Cookie
|
|
33
|
+
* 3. Each routeLoader calls createQwikSessionAdapter(cookie)
|
|
34
|
+
* 4. Without memoization, each call would create a different object
|
|
35
|
+
* → WeakMap would fail to deduplicate → duplicate network calls
|
|
36
|
+
*/
|
|
37
|
+
const adapterCache = new WeakMap<object, CookieAdapter>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a CookieAdapter from a Qwik City Cookie object.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import { createQwikSessionAdapter } from '@douvery/auth/qwik';
|
|
45
|
+
* import { createSessionResolver } from '@douvery/auth/session';
|
|
46
|
+
*
|
|
47
|
+
* const resolver = createSessionResolver({ ... });
|
|
48
|
+
*
|
|
49
|
+
* export const useMyLoader = routeLoader$(async ({ cookie }) => {
|
|
50
|
+
* const adapter = createQwikSessionAdapter(cookie);
|
|
51
|
+
* const token = await resolver.getAccessToken(adapter);
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function createQwikSessionAdapter(
|
|
56
|
+
cookie: QwikCookieLike,
|
|
57
|
+
): CookieAdapter {
|
|
58
|
+
let adapter = adapterCache.get(cookie);
|
|
59
|
+
if (adapter) return adapter;
|
|
60
|
+
|
|
61
|
+
adapter = {
|
|
62
|
+
get(name: string): string | undefined {
|
|
63
|
+
return cookie.get(name)?.value ?? undefined;
|
|
64
|
+
},
|
|
65
|
+
set(name: string, value: string, options: CookieSetOptions): void {
|
|
66
|
+
cookie.set(name, value, options as Record<string, unknown>);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
adapterCache.set(cookie, adapter);
|
|
71
|
+
return adapter;
|
|
72
|
+
}
|