@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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +26 -1
- package/package.json +53 -1
- package/src/config.js +226 -0
- package/src/cookies.js +107 -0
- package/src/errors.js +29 -0
- package/src/handler.js +158 -0
- package/src/index.d.ts +100 -0
- package/src/index.js +18 -0
- package/src/oauth.js +199 -0
- package/src/passwordless.js +94 -0
- package/src/routes.js +204 -0
- package/src/session.js +184 -0
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
|
+
}
|