dauth-md-node 3.0.1 → 4.0.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/src/router.ts ADDED
@@ -0,0 +1,444 @@
1
+ import { Router, Request, Response } from 'express';
2
+ import jwt from 'jsonwebtoken';
3
+ import { getServerBasePath } from './api/utils/config';
4
+ import {
5
+ deriveEncryptionKey,
6
+ encryptSession,
7
+ decryptSessionWithKeys,
8
+ SessionPayload,
9
+ } from './session';
10
+ import { generateCsrfToken, verifyCsrf } from './csrf';
11
+
12
+ export interface DauthRouterOptions {
13
+ domainName: string;
14
+ tsk: string;
15
+ dauthUrl?: string;
16
+ cookieName?: string;
17
+ csrfCookieName?: string;
18
+ maxAge?: number;
19
+ secure?: boolean;
20
+ previousTsk?: string;
21
+ sessionSalt?: string;
22
+ }
23
+
24
+ interface ResolvedConfig {
25
+ domainName: string;
26
+ dauthBasePath: string;
27
+ cookieName: string;
28
+ csrfCookieName: string;
29
+ maxAgeMs: number;
30
+ secure: boolean;
31
+ encKeys: Buffer[];
32
+ }
33
+
34
+ // Refresh lock to prevent race conditions on concurrent token rotation
35
+ const refreshLocks = new Map<string, Promise<SessionPayload | null>>();
36
+
37
+ function lockKey(refreshToken: string): string {
38
+ return refreshToken.substring(0, 16);
39
+ }
40
+
41
+ function clearStaleLocks(): void {
42
+ if (refreshLocks.size > 100) refreshLocks.clear();
43
+ }
44
+
45
+ async function resolveConfig(
46
+ opts: DauthRouterOptions
47
+ ): Promise<ResolvedConfig> {
48
+ const secure =
49
+ opts.secure ?? process.env.NODE_ENV !== 'development';
50
+ const cookieName =
51
+ opts.cookieName ??
52
+ (secure ? '__Host-dauth-session' : 'dauth-session');
53
+ const csrfCookieName =
54
+ opts.csrfCookieName ?? (secure ? '__Host-csrf' : 'csrf-token');
55
+ const maxAgeMs = (opts.maxAge ?? 30 * 24 * 3600) * 1000;
56
+
57
+ const keys: Buffer[] = [];
58
+ keys.push(await deriveEncryptionKey(opts.tsk, opts.sessionSalt));
59
+ if (opts.previousTsk) {
60
+ keys.push(
61
+ await deriveEncryptionKey(opts.previousTsk, opts.sessionSalt)
62
+ );
63
+ }
64
+
65
+ let dauthBasePath: string;
66
+ if (opts.dauthUrl) {
67
+ dauthBasePath = `${opts.dauthUrl.replace(/\/+$/, '')}/api/v1`;
68
+ } else {
69
+ dauthBasePath = getServerBasePath();
70
+ }
71
+
72
+ return {
73
+ domainName: opts.domainName,
74
+ dauthBasePath,
75
+ cookieName,
76
+ csrfCookieName,
77
+ maxAgeMs,
78
+ secure,
79
+ encKeys: keys,
80
+ };
81
+ }
82
+
83
+ function setSessionCookie(
84
+ res: Response,
85
+ payload: SessionPayload,
86
+ config: ResolvedConfig
87
+ ): void {
88
+ const encrypted = encryptSession(payload, config.encKeys[0]);
89
+ const cookieOpts: Record<string, unknown> = {
90
+ httpOnly: true,
91
+ secure: config.secure,
92
+ sameSite: 'lax',
93
+ maxAge: config.maxAgeMs,
94
+ path: '/',
95
+ };
96
+ // __Host- prefix requires no domain attribute
97
+ if (!config.secure) {
98
+ // Dev mode: no __Host- prefix, no domain restriction needed
99
+ }
100
+ res.cookie(config.cookieName, encrypted, cookieOpts);
101
+ }
102
+
103
+ function setCsrfCookie(res: Response, config: ResolvedConfig): void {
104
+ const csrfToken = generateCsrfToken();
105
+ res.cookie(config.csrfCookieName, csrfToken, {
106
+ httpOnly: false,
107
+ secure: config.secure,
108
+ sameSite: 'lax',
109
+ maxAge: config.maxAgeMs,
110
+ path: '/',
111
+ });
112
+ }
113
+
114
+ function clearCookies(res: Response, config: ResolvedConfig): void {
115
+ const baseOpts = { path: '/', secure: config.secure };
116
+ res.clearCookie(config.cookieName, baseOpts);
117
+ res.clearCookie(config.csrfCookieName, baseOpts);
118
+ }
119
+
120
+ function readSession(
121
+ req: Request,
122
+ config: ResolvedConfig
123
+ ): SessionPayload | null {
124
+ const cookie = req.cookies?.[config.cookieName];
125
+ if (!cookie) return null;
126
+ return decryptSessionWithKeys(cookie, config.encKeys);
127
+ }
128
+
129
+ function isTokenExpiringSoon(token: string, thresholdMs = 300_000): boolean {
130
+ try {
131
+ const decoded = jwt.decode(token) as { exp?: number } | null;
132
+ if (!decoded?.exp) return true;
133
+ return decoded.exp * 1000 - Date.now() < thresholdMs;
134
+ } catch {
135
+ return true;
136
+ }
137
+ }
138
+
139
+ async function maybeRefreshTokens(
140
+ session: SessionPayload,
141
+ config: ResolvedConfig,
142
+ res: Response
143
+ ): Promise<SessionPayload> {
144
+ if (!isTokenExpiringSoon(session.accessToken)) return session;
145
+
146
+ const key = lockKey(session.refreshToken);
147
+ clearStaleLocks();
148
+
149
+ const existingLock = refreshLocks.get(key);
150
+ if (existingLock) {
151
+ const result = await existingLock;
152
+ return result ?? session;
153
+ }
154
+
155
+ const refreshPromise = (async (): Promise<SessionPayload | null> => {
156
+ try {
157
+ const response = await fetch(
158
+ `${config.dauthBasePath}/app/${config.domainName}/refresh-token`,
159
+ {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({
163
+ refreshToken: session.refreshToken,
164
+ }),
165
+ }
166
+ );
167
+ if (!response.ok) return null;
168
+ const data = (await response.json()) as {
169
+ accessToken?: string;
170
+ refreshToken?: string;
171
+ };
172
+ if (!data.accessToken || !data.refreshToken) return null;
173
+ const newSession: SessionPayload = {
174
+ accessToken: data.accessToken,
175
+ refreshToken: data.refreshToken,
176
+ };
177
+ setSessionCookie(res, newSession, config);
178
+ return newSession;
179
+ } catch {
180
+ return null;
181
+ }
182
+ })();
183
+
184
+ refreshLocks.set(key, refreshPromise);
185
+
186
+ // Timeout safety net: clean lock after 10s
187
+ const timeout = setTimeout(() => refreshLocks.delete(key), 10_000);
188
+ refreshPromise.finally(() => {
189
+ clearTimeout(timeout);
190
+ refreshLocks.delete(key);
191
+ });
192
+
193
+ const result = await refreshPromise;
194
+ return result ?? session;
195
+ }
196
+
197
+ export function dauthRouter(opts: DauthRouterOptions): Router {
198
+ const router = Router();
199
+ let configPromise: Promise<ResolvedConfig> | null = null;
200
+
201
+ async function getConfig(): Promise<ResolvedConfig> {
202
+ if (!configPromise) configPromise = resolveConfig(opts);
203
+ return configPromise;
204
+ }
205
+
206
+ // POST /exchange-code — no CSRF (no prior session)
207
+ router.post('/exchange-code', async (req: Request, res: Response) => {
208
+ const config = await getConfig();
209
+ const { code } = req.body;
210
+ if (!code) {
211
+ return res
212
+ .status(400)
213
+ .send({ status: 'code-required', message: 'Code required' });
214
+ }
215
+
216
+ const response = await fetch(
217
+ `${config.dauthBasePath}/app/${config.domainName}/exchange-code`,
218
+ {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ code }),
222
+ }
223
+ );
224
+ if (!response.ok) {
225
+ return res
226
+ .status(response.status)
227
+ .send({ status: 'code-invalid', message: 'Code invalid' });
228
+ }
229
+ const data = (await response.json()) as {
230
+ accessToken: string;
231
+ refreshToken: string;
232
+ isNewUser: boolean;
233
+ };
234
+
235
+ setSessionCookie(
236
+ res,
237
+ {
238
+ accessToken: data.accessToken,
239
+ refreshToken: data.refreshToken,
240
+ },
241
+ config
242
+ );
243
+ setCsrfCookie(res, config);
244
+
245
+ // Fetch user data to return
246
+ const userResponse = await fetch(
247
+ `${config.dauthBasePath}/app/${config.domainName}/user`,
248
+ {
249
+ method: 'GET',
250
+ headers: { Authorization: data.accessToken },
251
+ }
252
+ );
253
+ const userData = (await userResponse.json()) as {
254
+ user?: unknown;
255
+ domain?: unknown;
256
+ };
257
+
258
+ return res.status(200).send({
259
+ user: userData.user,
260
+ domain: userData.domain,
261
+ isNewUser: data.isNewUser,
262
+ });
263
+ });
264
+
265
+ // GET /session — no CSRF (read-only)
266
+ router.get('/session', async (req: Request, res: Response) => {
267
+ const config = await getConfig();
268
+ const session = readSession(req, config);
269
+ if (!session) {
270
+ return res
271
+ .status(401)
272
+ .send({ status: 'no-session', message: 'Not authenticated' });
273
+ }
274
+
275
+ const refreshed = await maybeRefreshTokens(session, config, res);
276
+
277
+ const userResponse = await fetch(
278
+ `${config.dauthBasePath}/app/${config.domainName}/user`,
279
+ {
280
+ method: 'GET',
281
+ headers: { Authorization: refreshed.accessToken },
282
+ }
283
+ );
284
+ if (!userResponse.ok) {
285
+ clearCookies(res, config);
286
+ return res
287
+ .status(401)
288
+ .send({ status: 'session-invalid', message: 'Session expired' });
289
+ }
290
+ const userData = (await userResponse.json()) as {
291
+ user?: unknown;
292
+ domain?: unknown;
293
+ };
294
+ return res.status(200).send({
295
+ user: userData.user,
296
+ domain: userData.domain,
297
+ });
298
+ });
299
+
300
+ // POST /logout — CSRF required
301
+ router.post('/logout', async (req: Request, res: Response) => {
302
+ const config = await getConfig();
303
+ if (!verifyCsrf(req, config.csrfCookieName)) {
304
+ return res
305
+ .status(403)
306
+ .send({ status: 'csrf-invalid', message: 'CSRF token invalid' });
307
+ }
308
+ const session = readSession(req, config);
309
+ if (session) {
310
+ // Revoke refresh token server-to-server (fire-and-forget)
311
+ fetch(
312
+ `${config.dauthBasePath}/app/${config.domainName}/logout`,
313
+ {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({
317
+ refreshToken: session.refreshToken,
318
+ }),
319
+ }
320
+ ).catch(() => {});
321
+ }
322
+ clearCookies(res, config);
323
+ return res
324
+ .status(200)
325
+ .send({ status: 'success', message: 'Logged out' });
326
+ });
327
+
328
+ // PATCH /user — CSRF required
329
+ router.patch('/user', async (req: Request, res: Response) => {
330
+ const config = await getConfig();
331
+ if (!verifyCsrf(req, config.csrfCookieName)) {
332
+ return res
333
+ .status(403)
334
+ .send({ status: 'csrf-invalid', message: 'CSRF token invalid' });
335
+ }
336
+ const session = readSession(req, config);
337
+ if (!session) {
338
+ return res
339
+ .status(401)
340
+ .send({ status: 'no-session', message: 'Not authenticated' });
341
+ }
342
+ const refreshed = await maybeRefreshTokens(session, config, res);
343
+
344
+ const response = await fetch(
345
+ `${config.dauthBasePath}/app/${config.domainName}/user`,
346
+ {
347
+ method: 'PATCH',
348
+ headers: {
349
+ 'Content-Type': 'application/json',
350
+ Authorization: refreshed.accessToken,
351
+ },
352
+ body: JSON.stringify(req.body),
353
+ }
354
+ );
355
+ const data = await response.json();
356
+ return res.status(response.status).send(data);
357
+ });
358
+
359
+ // DELETE /user — CSRF required
360
+ router.delete('/user', async (req: Request, res: Response) => {
361
+ const config = await getConfig();
362
+ if (!verifyCsrf(req, config.csrfCookieName)) {
363
+ return res
364
+ .status(403)
365
+ .send({ status: 'csrf-invalid', message: 'CSRF token invalid' });
366
+ }
367
+ const session = readSession(req, config);
368
+ if (!session) {
369
+ return res
370
+ .status(401)
371
+ .send({ status: 'no-session', message: 'Not authenticated' });
372
+ }
373
+
374
+ const response = await fetch(
375
+ `${config.dauthBasePath}/app/${config.domainName}/user`,
376
+ {
377
+ method: 'DELETE',
378
+ headers: { Authorization: session.accessToken },
379
+ }
380
+ );
381
+ const data = await response.json();
382
+ clearCookies(res, config);
383
+ return res.status(response.status).send(data);
384
+ });
385
+
386
+ // GET /profile-redirect — CSRF required (generates profile code)
387
+ router.get(
388
+ '/profile-redirect',
389
+ async (req: Request, res: Response) => {
390
+ const config = await getConfig();
391
+ if (!verifyCsrf(req, config.csrfCookieName)) {
392
+ return res.status(403).send({
393
+ status: 'csrf-invalid',
394
+ message: 'CSRF token invalid',
395
+ });
396
+ }
397
+ const session = readSession(req, config);
398
+ if (!session) {
399
+ return res.status(401).send({
400
+ status: 'no-session',
401
+ message: 'Not authenticated',
402
+ });
403
+ }
404
+ const refreshed = await maybeRefreshTokens(
405
+ session,
406
+ config,
407
+ res
408
+ );
409
+
410
+ const response = await fetch(
411
+ `${config.dauthBasePath}/app/${config.domainName}/profile-code`,
412
+ {
413
+ method: 'POST',
414
+ headers: {
415
+ 'Content-Type': 'application/json',
416
+ Authorization: refreshed.accessToken,
417
+ },
418
+ }
419
+ );
420
+ if (!response.ok) {
421
+ return res.status(response.status).send({
422
+ status: 'profile-code-error',
423
+ message: 'Could not generate profile code',
424
+ });
425
+ }
426
+ const data = (await response.json()) as { code: string };
427
+
428
+ // Build redirect URL to dauth frontend
429
+ const dauthFrontendUrl = opts.dauthUrl
430
+ ? opts.dauthUrl.replace(/\/+$/, '')
431
+ : process.env.DAUTH_URL
432
+ ? process.env.DAUTH_URL.replace(/\/+$/, '')
433
+ : process.env.NODE_ENV === 'development'
434
+ ? 'http://localhost:5185'
435
+ : 'https://dauth.ovh';
436
+
437
+ return res.status(200).send({
438
+ redirectUrl: `${dauthFrontendUrl}/${config.domainName}/update-user?code=${data.code}`,
439
+ });
440
+ }
441
+ );
442
+
443
+ return router;
444
+ }
package/src/session.ts ADDED
@@ -0,0 +1,81 @@
1
+ import crypto from 'crypto';
2
+
3
+ export interface SessionPayload {
4
+ accessToken: string;
5
+ refreshToken: string;
6
+ }
7
+
8
+ const INFO = 'dauth-cookie-enc-v1';
9
+ const DEFAULT_SALT = Buffer.from(
10
+ 'a3f8c1d7e9b24f6081c5d3a7e2f49b0653d81f7a2e94c0b6d8f3a5e1c7b09d42',
11
+ 'hex'
12
+ );
13
+
14
+ export async function deriveEncryptionKey(
15
+ tsk: string,
16
+ salt?: string
17
+ ): Promise<Buffer> {
18
+ const saltBuf = salt ? Buffer.from(salt, 'hex') : DEFAULT_SALT;
19
+ return new Promise((resolve, reject) => {
20
+ crypto.hkdf(
21
+ 'sha256',
22
+ Buffer.from(tsk),
23
+ saltBuf,
24
+ INFO,
25
+ 32,
26
+ (err, derivedKey) => {
27
+ if (err) return reject(err);
28
+ resolve(Buffer.from(derivedKey));
29
+ }
30
+ );
31
+ });
32
+ }
33
+
34
+ export function encryptSession(
35
+ payload: SessionPayload,
36
+ key: Buffer
37
+ ): string {
38
+ const nonce = crypto.randomBytes(12);
39
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
40
+ const plaintext = JSON.stringify(payload);
41
+ const encrypted = Buffer.concat([
42
+ cipher.update(plaintext, 'utf8'),
43
+ cipher.final(),
44
+ ]);
45
+ const authTag = cipher.getAuthTag();
46
+ // Format: base64(nonce + ciphertext + authTag)
47
+ return Buffer.concat([nonce, encrypted, authTag]).toString('base64');
48
+ }
49
+
50
+ export function decryptSession(
51
+ ciphertext: string,
52
+ key: Buffer
53
+ ): SessionPayload | null {
54
+ try {
55
+ const buf = Buffer.from(ciphertext, 'base64');
56
+ if (buf.length < 12 + 16) return null; // nonce(12) + authTag(16) minimum
57
+ const nonce = buf.subarray(0, 12);
58
+ const authTag = buf.subarray(buf.length - 16);
59
+ const encrypted = buf.subarray(12, buf.length - 16);
60
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
61
+ decipher.setAuthTag(authTag);
62
+ const decrypted = Buffer.concat([
63
+ decipher.update(encrypted),
64
+ decipher.final(),
65
+ ]);
66
+ return JSON.parse(decrypted.toString('utf8')) as SessionPayload;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export function decryptSessionWithKeys(
73
+ ciphertext: string,
74
+ keys: Buffer[]
75
+ ): SessionPayload | null {
76
+ for (const key of keys) {
77
+ const result = decryptSession(ciphertext, key);
78
+ if (result) return result;
79
+ }
80
+ return null;
81
+ }