@voult/core 0.0.0 → 0.2.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/session.js ADDED
@@ -0,0 +1,184 @@
1
+ import { VoultClient, refreshSession } from '@voult/sdk';
2
+ import { readCookieHeader } from './cookies.js';
3
+
4
+ export const COOKIE_ACCESS = 'voult_access';
5
+ export const COOKIE_REFRESH = 'voult_refresh';
6
+ export const COOKIE_USER = 'voult_user';
7
+ export const COOKIE_OAUTH = 'voult_oauth';
8
+ export const COOKIE_MFA_PENDING = 'voult_mfa_pending';
9
+
10
+ export const ACCESS_MAX_AGE_MS = 60 * 60 * 1000;
11
+ export const REFRESH_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
12
+ export const MFA_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
13
+ export const OAUTH_COOKIE_MAX_AGE_MS = 10 * 60 * 1000; // matches Voult's signed state lifetime
14
+
15
+ /**
16
+ * Cookies are described as `{ name, value, options }` and written by the adapter: core signs
17
+ * them with WebCrypto, @voult/express hands them to `res.cookie()`. Same bytes either way.
18
+ * @typedef {import('./index.js').VoultConfig} VoultConfig
19
+ * @typedef {{ name: string, value: string, options: Record<string, unknown> }} CookieToSet
20
+ * @typedef {(name: string) => unknown} ReadCookie
21
+ */
22
+
23
+ /**
24
+ * @param {VoultConfig} config
25
+ * @param {{ maxAge?: number }} [overrides]
26
+ */
27
+ export function cookieOptions(config, overrides = {}) {
28
+ return {
29
+ httpOnly: true,
30
+ signed: Boolean(config.sessionSecret),
31
+ sameSite: 'lax',
32
+ secure: globalThis.process?.env?.NODE_ENV === 'production',
33
+ path: '/',
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ /** @returns {CookieToSet} */
39
+ export function setCookie(config, name, value, overrides) {
40
+ return { name, value, options: cookieOptions(config, overrides) };
41
+ }
42
+
43
+ /** Express `res.clearCookie()`: an empty value that expired in 1970. @returns {CookieToSet} */
44
+ export function clearCookie(config, name) {
45
+ return { name, value: '', options: { ...cookieOptions(config), expires: new Date(1) } };
46
+ }
47
+
48
+ /** @returns {CookieToSet[]} */
49
+ export function clearSessionCookies(config) {
50
+ return [COOKIE_ACCESS, COOKIE_REFRESH, COOKIE_USER].map((name) => clearCookie(config, name));
51
+ }
52
+
53
+ /**
54
+ * @param {unknown} value
55
+ * @returns {object | null}
56
+ */
57
+ export function parseUserCookie(value) {
58
+ if (!value) return null;
59
+ if (typeof value === 'object') return value;
60
+ if (typeof value !== 'string') return null;
61
+ try {
62
+ return JSON.parse(value);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** @param {VoultConfig} config */
69
+ export function createVoultClient(config) {
70
+ return new VoultClient({ clientId: config.clientId, clientSecret: config.clientSecret, baseURL: config.baseURL });
71
+ }
72
+
73
+ /**
74
+ * Restore the SDK session from cookies (cookie strategy) or the Authorization header (bearer).
75
+ * Returns what came in, so the outgoing side knows whether there was a session to clear.
76
+ * @param {VoultClient} client
77
+ * @param {{ read: ReadCookie, authorization?: string | null, refreshToken?: unknown }} incoming
78
+ * @param {VoultConfig} config
79
+ */
80
+ export function restoreSession(client, { read, authorization, refreshToken: bearerRefresh }, config) {
81
+ let accessToken;
82
+ let refreshToken;
83
+ let user = null;
84
+
85
+ if (config.session.strategy === 'cookie') {
86
+ accessToken = read(COOKIE_ACCESS);
87
+ refreshToken = read(COOKIE_REFRESH);
88
+ user = parseUserCookie(read(COOKIE_USER));
89
+ } else {
90
+ if (typeof authorization === 'string' && authorization.startsWith('Bearer ')) {
91
+ accessToken = authorization.slice('Bearer '.length).trim();
92
+ }
93
+ refreshToken = bearerRefresh;
94
+ }
95
+
96
+ if (typeof accessToken === 'string' && accessToken.length > 0) {
97
+ client.setSession(user || {}, accessToken, refreshToken || null);
98
+ }
99
+
100
+ return { accessToken: client.accessToken || null, refreshToken: client.refreshToken || null };
101
+ }
102
+
103
+ /**
104
+ * The cookies that persist the client's session when the response goes out: fresh tokens,
105
+ * or a clear when a session that came in is gone (sign-out). Cookie strategy only.
106
+ * @returns {CookieToSet[]}
107
+ */
108
+ export function sessionCookies(client, incoming, config) {
109
+ if (config.session.strategy !== 'cookie' || !client) return [];
110
+
111
+ if (client.accessToken) {
112
+ return [
113
+ setCookie(config, COOKIE_ACCESS, client.accessToken, { maxAge: ACCESS_MAX_AGE_MS }),
114
+ ...(client.refreshToken ? [setCookie(config, COOKIE_REFRESH, client.refreshToken, { maxAge: REFRESH_MAX_AGE_MS })] : []),
115
+ ...(client.user ? [setCookie(config, COOKIE_USER, JSON.stringify(client.user), { maxAge: REFRESH_MAX_AGE_MS })] : []),
116
+ ];
117
+ }
118
+
119
+ return incoming?.accessToken ? clearSessionCookies(config) : [];
120
+ }
121
+
122
+ /**
123
+ * The access cookie lives 1h, the refresh cookie 30 days: when only the refresh cookie is
124
+ * left, get a new access token instead of treating the user as signed out. The new tokens go
125
+ * out with the response (sessionCookies). A dead refresh token clears the session cookies,
126
+ * which this returns.
127
+ * @param {VoultClient} client
128
+ * @param {ReadCookie} read
129
+ * @param {VoultConfig} config
130
+ * @returns {Promise<CookieToSet[]>}
131
+ */
132
+ export async function renewSession(client, read, config) {
133
+ if (!client || client.accessToken || config?.session?.strategy !== 'cookie') return [];
134
+
135
+ const refreshToken = read(COOKIE_REFRESH);
136
+ if (typeof refreshToken !== 'string' || !refreshToken) return [];
137
+
138
+ client.refreshToken = refreshToken;
139
+ try {
140
+ await refreshSession(client);
141
+ // The SDK only counts a client as signed in with a user object: reuse the (30-day)
142
+ // user cookie, or {} so the caller can fetch /user/me.
143
+ client.user = client.user ?? parseUserCookie(read(COOKIE_USER)) ?? {};
144
+ return [];
145
+ } catch {
146
+ client.clearSession();
147
+ return clearSessionCookies(config);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Cookie strategy keeps tokens in httpOnly cookies; bearer returns them in JSON.
153
+ * @param {object} result
154
+ * @param {VoultConfig} config
155
+ */
156
+ export function toPublicAuthResult(result, config) {
157
+ if (!result || result.mfaRequired || config.session.strategy !== 'cookie') return result;
158
+ const { accessToken, refreshToken, token, ...rest } = result;
159
+ return rest;
160
+ }
161
+
162
+ /**
163
+ * The Voult session behind a Fetch API `Request`: a fresh VoultClient with the cookie or bearer
164
+ * session restored. For adapters (`@voult/next`'s `getSession`, middleware) and the router.
165
+ * @param {Request} request
166
+ * @param {VoultConfig} config
167
+ * @param {{ body?: Record<string, unknown> }} [options]
168
+ */
169
+ export async function getSessionFromRequest(request, config, { body } = {}) {
170
+ const { cookies, signedCookies } = config.session.strategy === 'cookie'
171
+ ? await readCookieHeader(request.headers.get('cookie'), config.sessionSecret)
172
+ : { cookies: {}, signedCookies: {} };
173
+ /** @type {ReadCookie} */
174
+ const read = (name) => signedCookies[name] ?? cookies[name];
175
+
176
+ const client = createVoultClient(config);
177
+ const incoming = restoreSession(client, {
178
+ read,
179
+ authorization: request.headers.get('authorization'),
180
+ refreshToken: body?.refreshToken || request.headers.get('x-refresh-token'),
181
+ }, config);
182
+
183
+ return { client, incoming, read };
184
+ }