@voult/core 0.0.0 → 0.1.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 ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@voult/core` are documented here.
4
+
5
+ ## 0.1.0
6
+
7
+ First release. The Voult router as one function on the Fetch API, so adapters only translate requests:
8
+
9
+ - `createVoultHandler(config)` → `handle(request, { basePath, notFound }) → Response`: every route `@voult/express` 0.2.1 served (session, password sign-up/sign-in, sign-out, refresh, profile, password reset, email verification, MFA, hosted OAuth with the browser-bound nonce), with session renewal from the refresh cookie.
10
+ - Cookies are byte-compatible with Express + `cookie-parser` (`s:` + HMAC-SHA256 signatures), signed with WebCrypto.
11
+ - No framework imports and no Node-only APIs in `src/` (a test checks), so it can run on Node 20+, Next.js route handlers and Edge runtimes.
12
+ - For adapters: `resolveConfig`, `loadConfigFromEnv`, `getSessionFromRequest`, `restoreSession`, `renewSession`, `sessionCookies`, `normalizeVoultError`, and the cookie helpers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Olabode
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/README.md CHANGED
@@ -1 +1,26 @@
1
- Not released yet. See https://github.com/voult-dev/voult-sdk
1
+ # @voult/core
2
+
3
+ The [Voult](https://github.com/voult-dev/voult) auth router on the Fetch API: `Request → Response`.
4
+
5
+ **You probably want an adapter instead:** [`@voult/express`](../express) for Express, `@voult/next` for Next.js (coming soon). They depend on this package; apps don't install it directly.
6
+
7
+ ## For adapter authors
8
+
9
+ ```js
10
+ import { createVoultHandler, loadConfigFromEnv } from '@voult/core';
11
+
12
+ const handle = createVoultHandler(loadConfigFromEnv());
13
+
14
+ // Any Fetch API runtime: a Next.js route handler, a Worker, Bun, Deno, Node 20+.
15
+ export async function fetch(request) {
16
+ return handle(request, { basePath: '/api/auth' });
17
+ }
18
+ ```
19
+
20
+ - `basePath`: where the routes are mounted. Requests outside it, or to paths with no route, get `notFound(request)` (default: 404 JSON; return `null` to fall through to your framework).
21
+ - The OAuth callback URL is built from the request URL, so pass the URL the browser used (or set `VOULT_OAUTH_CALLBACK_URL`).
22
+ - Cookies (httpOnly, `SameSite=Lax`, `Secure` in production) use the same names and signed format as Express + `cookie-parser`.
23
+
24
+ `getSessionFromRequest(request, config)` gives you the request's `VoultClient` with its session restored, for server-side helpers like `getSession()`.
25
+
26
+ `@voult/sdk` is a peer dependency. Node 20+ (needs `fetch` and WebCrypto).
package/package.json CHANGED
@@ -1 +1,53 @@
1
- { "name": "@voult/core", "version": "0.0.0", "description": "Placeholder: not released yet", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/voult-dev/voult-sdk.git" } }
1
+ {
2
+ "name": "@voult/core",
3
+ "version": "0.1.0",
4
+ "description": "The Voult auth router on the Fetch API (Request → Response), shared by @voult/express and @voult/next",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "src",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test tests/*.test.js"
25
+ },
26
+ "keywords": [
27
+ "voult",
28
+ "authentication",
29
+ "fetch",
30
+ "edge"
31
+ ],
32
+ "author": "Samuel Olabode",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@voult/sdk": ">=0.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "@voult/sdk": "*",
39
+ "cookie-signature": "^1.0.6"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/voult-dev/voult-sdk.git",
44
+ "directory": "packages/core"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/voult-dev/voult-sdk/issues"
48
+ },
49
+ "homepage": "https://github.com/voult-dev/voult-sdk/tree/main/packages/core#readme",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/config.js ADDED
@@ -0,0 +1,226 @@
1
+ import { DEFAULT_BASE_URL } from '@voult/sdk';
2
+
3
+ /**
4
+ * @typedef {import('./index.js').VoultConfig} VoultExpressConfig
5
+ * @typedef {import('./index.js').LoadConfigFromEnvOptions} LoadConfigFromEnvOptions
6
+ * @typedef {import('./index.js').ResolveConfigOptions} CreateVoultRouterOptions
7
+ */
8
+
9
+ // No `process` on the Edge: read it through globalThis.
10
+ const processEnv = () => globalThis.process?.env ?? {};
11
+
12
+ const ENV_FIELDS = {
13
+ baseURL: { canonical: 'VOULT_BASE_URL', aliases: ['BASE_URL'] },
14
+ clientId: { canonical: 'VOULT_CLIENT_ID', aliases: ['CLIENT_ID'] },
15
+ clientSecret: { canonical: 'VOULT_CLIENT_SECRET', aliases: ['CLIENT_SECRET'] },
16
+ sessionSecret: { canonical: 'VOULT_SESSION_SECRET', aliases: ['SESSION_SECRET'] },
17
+ appUrl: { canonical: 'VOULT_APP_URL', aliases: ['APP_URL'] },
18
+ sessionStrategy: { canonical: 'VOULT_SESSION_STRATEGY', aliases: [] },
19
+ oauthCallbackUrl: { canonical: 'VOULT_OAUTH_CALLBACK_URL', aliases: [] },
20
+ };
21
+
22
+ const warnedLegacyAliases = new Set();
23
+
24
+ /**
25
+ * Read a config value from canonical env names, falling back to deprecated aliases.
26
+ * @param {NodeJS.Dict<string | undefined>} env
27
+ * @param {{ canonical: string, aliases: string[] }} field
28
+ * @returns {string | undefined}
29
+ */
30
+ function readEnvValue(env, field) {
31
+ const canonicalValue = trimToUndefined(env[field.canonical]);
32
+ if (canonicalValue) {
33
+ return canonicalValue;
34
+ }
35
+
36
+ for (const alias of field.aliases) {
37
+ const aliasValue = trimToUndefined(env[alias]);
38
+ if (!aliasValue) {
39
+ continue;
40
+ }
41
+
42
+ if (!warnedLegacyAliases.has(alias)) {
43
+ warnedLegacyAliases.add(alias);
44
+ console.warn(
45
+ `[voult] ${alias} is deprecated; use ${field.canonical} instead. This alias will be removed in a future release.`
46
+ );
47
+ }
48
+
49
+ return aliasValue;
50
+ }
51
+
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * @param {unknown} value
57
+ * @returns {string | undefined}
58
+ */
59
+ function trimToUndefined(value) {
60
+ if (typeof value !== 'string') {
61
+ return undefined;
62
+ }
63
+
64
+ const trimmed = value.trim();
65
+ return trimmed === '' ? undefined : trimmed;
66
+ }
67
+
68
+ // Each Voult instance serves its own developer dashboard at /dashboard.
69
+ const dashboardUrl = (baseURL) => new URL('/dashboard', baseURL).href;
70
+
71
+ /**
72
+ * @param {NodeJS.Dict<string | undefined>} env
73
+ * @returns {string}
74
+ */
75
+ function resolveNodeEnv(env) {
76
+ return (
77
+ trimToUndefined(env.NODE_ENV) ??
78
+ trimToUndefined(processEnv().NODE_ENV) ??
79
+ 'development'
80
+ );
81
+ }
82
+
83
+ /**
84
+ * @param {unknown} value
85
+ * @param {string} envName
86
+ * @returns {string}
87
+ */
88
+ function requireHttpUrl(value, envName) {
89
+ const raw = trimToUndefined(value);
90
+ if (!raw) {
91
+ return DEFAULT_BASE_URL;
92
+ }
93
+
94
+ let parsed;
95
+ try {
96
+ parsed = new URL(raw);
97
+ } catch {
98
+ parsed = null;
99
+ }
100
+
101
+ if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
102
+ throw new Error(
103
+ `[voult] Invalid ${envName} "${raw}". Set it to an absolute http(s) URL, for example https://api.voult.dev or https://staging.voult.dev.`
104
+ );
105
+ }
106
+
107
+ return raw;
108
+ }
109
+
110
+ const OAUTH_PATH_DEFAULTS = { successPath: '/', mfaPath: '/mfa', errorPath: '/login' };
111
+
112
+ /**
113
+ * Where the hosted-OAuth callback sends the browser, relative to VOULT_APP_URL.
114
+ * @param {Partial<Record<keyof typeof OAUTH_PATH_DEFAULTS, string>> | undefined} oauth
115
+ */
116
+ function normalizeOAuthPaths(oauth = {}) {
117
+ return Object.fromEntries(Object.entries(OAUTH_PATH_DEFAULTS).map(([key, fallback]) => {
118
+ const value = trimToUndefined(oauth[key]) ?? fallback;
119
+ if (!value.startsWith('/') || value.startsWith('//')) {
120
+ throw new Error(`[voult] oauth.${key} must be a path starting with "/" (got "${value}").`);
121
+ }
122
+ return [key, value];
123
+ }));
124
+ }
125
+
126
+ /**
127
+ * @param {VoultExpressConfig} config
128
+ * @param {NodeJS.Dict<string | undefined>} env
129
+ * @returns {VoultExpressConfig}
130
+ */
131
+ function normalizeConfig(config, env) {
132
+ const clientId = trimToUndefined(config.clientId);
133
+ const clientSecret = trimToUndefined(config.clientSecret);
134
+ const sessionSecret = trimToUndefined(config.sessionSecret);
135
+ const requestedStrategy = trimToUndefined(config.session?.strategy)?.toLowerCase() ?? 'cookie';
136
+ if (requestedStrategy !== 'cookie' && requestedStrategy !== 'bearer') {
137
+ throw new Error(
138
+ `[voult] Invalid VOULT_SESSION_STRATEGY "${config.session.strategy}". Use "cookie" (default, httpOnly cookies) or "bearer" (tokens returned in JSON).`
139
+ );
140
+ }
141
+ const strategy = requestedStrategy;
142
+ const baseURL = requireHttpUrl(config.baseURL, 'VOULT_BASE_URL');
143
+
144
+ if (!clientId) {
145
+ throw new Error(
146
+ `[voult] Missing VOULT_CLIENT_ID. Create an App in the Voult dashboard (${dashboardUrl(baseURL)}), then copy the Client ID into your .env as VOULT_CLIENT_ID.`
147
+ );
148
+ }
149
+
150
+ if (!clientSecret) {
151
+ throw new Error(
152
+ `[voult] Missing VOULT_CLIENT_SECRET. Copy the Client Secret from your App in the Voult dashboard (${dashboardUrl(baseURL)}) into your .env as VOULT_CLIENT_SECRET. The secret is required for this Express BFF and must not be sent to the browser.`
153
+ );
154
+ }
155
+
156
+ const appUrl = trimToUndefined(config.appUrl)
157
+ ? requireHttpUrl(config.appUrl, 'VOULT_APP_URL')
158
+ : undefined;
159
+ const oauthCallbackUrl = trimToUndefined(config.oauthCallbackUrl)
160
+ ? requireHttpUrl(config.oauthCallbackUrl, 'VOULT_OAUTH_CALLBACK_URL')
161
+ : undefined;
162
+
163
+ if (strategy === 'cookie' && !sessionSecret && resolveNodeEnv(env) === 'production') {
164
+ throw new Error(
165
+ '[voult] Cookie sessions require VOULT_SESSION_SECRET in production. Add a long random value to your .env (openssl rand -hex 32). If the client stores tokens itself, set VOULT_SESSION_STRATEGY=bearer instead.'
166
+ );
167
+ }
168
+
169
+ return {
170
+ baseURL,
171
+ clientId,
172
+ clientSecret,
173
+ sessionSecret,
174
+ appUrl,
175
+ oauthCallbackUrl,
176
+ oauth: normalizeOAuthPaths(config.oauth),
177
+ session: { strategy },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Load BFF config from process env (or a provided env map).
183
+ * Canonical names are `VOULT_*`. Legacy aliases warn once per process.
184
+ *
185
+ * @param {LoadConfigFromEnvOptions} [options]
186
+ * @returns {VoultExpressConfig}
187
+ */
188
+ export function loadConfigFromEnv(options = {}) {
189
+ const env = options.env ?? processEnv();
190
+ const overrides = options.overrides ?? {};
191
+
192
+ return normalizeConfig(
193
+ {
194
+ baseURL: overrides.baseURL ?? readEnvValue(env, ENV_FIELDS.baseURL),
195
+ clientId: overrides.clientId ?? readEnvValue(env, ENV_FIELDS.clientId),
196
+ clientSecret: overrides.clientSecret ?? readEnvValue(env, ENV_FIELDS.clientSecret),
197
+ sessionSecret: overrides.sessionSecret ?? readEnvValue(env, ENV_FIELDS.sessionSecret),
198
+ appUrl: overrides.appUrl ?? readEnvValue(env, ENV_FIELDS.appUrl),
199
+ oauthCallbackUrl: overrides.oauthCallbackUrl ?? readEnvValue(env, ENV_FIELDS.oauthCallbackUrl),
200
+ oauth: overrides.oauth,
201
+ session: {
202
+ strategy: overrides.session?.strategy ?? readEnvValue(env, ENV_FIELDS.sessionStrategy),
203
+ },
204
+ },
205
+ env
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Resolve router/middleware options into a normalized config.
211
+ * @param {CreateVoultRouterOptions} [options]
212
+ * @returns {VoultExpressConfig}
213
+ */
214
+ export function resolveConfig(options = {}) {
215
+ const env = options.env ?? processEnv();
216
+
217
+ // `oauth` paths may also be passed at the top level: createVoultRouter({ oauth: { ... } }).
218
+ if (options.config) {
219
+ return normalizeConfig({ ...options.config, oauth: options.oauth ?? options.config.oauth }, env);
220
+ }
221
+
222
+ return loadConfigFromEnv({
223
+ env,
224
+ overrides: options.oauth ? { ...options.overrides, oauth: options.oauth } : options.overrides,
225
+ });
226
+ }
package/src/cookies.js ADDED
@@ -0,0 +1,107 @@
1
+ // Cookies in exactly the format Express + cookie-parser use (so sessions survive the switch
2
+ // between adapters), built on WebCrypto so the same code runs on Node and the Edge.
3
+ // signed value: `s:<value>.<base64 HMAC-SHA256(value), padding stripped>` (cookie-signature)
4
+ // Set-Cookie: `name=<encoded>; Max-Age=; Path=; Expires=; HttpOnly; Secure; SameSite=`
5
+
6
+ const encoder = new TextEncoder();
7
+ const hmacKeys = new Map();
8
+
9
+ function hmacKey(secret) {
10
+ if (!hmacKeys.has(secret)) {
11
+ hmacKeys.set(
12
+ secret,
13
+ globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
14
+ );
15
+ }
16
+ return hmacKeys.get(secret);
17
+ }
18
+
19
+ export function base64(bytes) {
20
+ let binary = '';
21
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
22
+ return btoa(binary);
23
+ }
24
+
25
+ /** cookie-signature `sign()`: `<value>.<mac>` */
26
+ export async function sign(value, secret) {
27
+ const mac = await globalThis.crypto.subtle.sign('HMAC', await hmacKey(secret), encoder.encode(value));
28
+ return `${value}.${base64(mac).replace(/=+$/, '')}`;
29
+ }
30
+
31
+ function safeEqual(a, b) {
32
+ if (a.length !== b.length) return false;
33
+ let diff = 0;
34
+ for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
35
+ return diff === 0;
36
+ }
37
+
38
+ /** cookie-signature `unsign()`: the value, or `false` when the signature is wrong. */
39
+ export async function unsign(signed, secret) {
40
+ const dot = signed.lastIndexOf('.');
41
+ if (dot < 0) return false;
42
+ const value = signed.slice(0, dot);
43
+ return safeEqual(await sign(value, secret), signed) ? value : false;
44
+ }
45
+
46
+ function decode(value) {
47
+ if (!value.includes('%')) return value;
48
+ try {
49
+ return decodeURIComponent(value);
50
+ } catch {
51
+ return value;
52
+ }
53
+ }
54
+
55
+ /** Parse a `Cookie` header like the `cookie` package: first occurrence wins, quotes stripped. */
56
+ export function parseCookieHeader(header) {
57
+ const cookies = Object.create(null);
58
+ for (const part of (header ?? '').split(';')) {
59
+ const eq = part.indexOf('=');
60
+ if (eq < 0) continue;
61
+ const name = part.slice(0, eq).trim();
62
+ if (!name || Object.hasOwn(cookies, name)) continue;
63
+ let value = part.slice(eq + 1).trim();
64
+ if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
65
+ cookies[name] = decode(value);
66
+ }
67
+ return cookies;
68
+ }
69
+
70
+ /**
71
+ * cookie-parser semantics: with a secret, `s:` values move to `signedCookies` (`false` when
72
+ * tampered with); everything else stays in `cookies`.
73
+ */
74
+ export async function readCookieHeader(header, secret) {
75
+ const cookies = parseCookieHeader(header);
76
+ const signedCookies = Object.create(null);
77
+ if (secret) {
78
+ for (const [name, value] of Object.entries(cookies)) {
79
+ if (!value.startsWith('s:')) continue;
80
+ signedCookies[name] = await unsign(value.slice(2), secret);
81
+ delete cookies[name];
82
+ }
83
+ }
84
+ return { cookies, signedCookies };
85
+ }
86
+
87
+ /** Express `res.cookie()` output for an already-signed value. `maxAge` is in milliseconds. */
88
+ export function serializeCookie(name, value, options = {}) {
89
+ let str = `${name}=${encodeURIComponent(value)}`;
90
+ let { expires } = options;
91
+ if (options.maxAge != null) {
92
+ expires = new Date(Date.now() + options.maxAge);
93
+ str += `; Max-Age=${Math.floor(options.maxAge / 1000)}`;
94
+ }
95
+ str += `; Path=${options.path ?? '/'}`;
96
+ if (expires) str += `; Expires=${expires.toUTCString()}`;
97
+ if (options.httpOnly) str += '; HttpOnly';
98
+ if (options.secure) str += '; Secure';
99
+ if (options.sameSite) str += `; SameSite=${options.sameSite[0].toUpperCase()}${options.sameSite.slice(1).toLowerCase()}`;
100
+ return str;
101
+ }
102
+
103
+ /** A Set-Cookie header for a `{ name, value, options }` cookie, signing it when `options.signed`. */
104
+ export async function setCookieHeader({ name, value, options }, secret) {
105
+ const final = options.signed ? `s:${await sign(value, secret)}` : value;
106
+ return serializeCookie(name, final, options);
107
+ }
package/src/errors.js ADDED
@@ -0,0 +1,29 @@
1
+ import { VoultError } from '@voult/sdk';
2
+
3
+ /**
4
+ * Normalize SDK and unexpected errors into a stable JSON shape.
5
+ * @param {unknown} err
6
+ */
7
+ export function normalizeVoultError(err) {
8
+ if (err instanceof VoultError) {
9
+ const status = err.status ?? 500;
10
+ return {
11
+ error: {
12
+ code: err.code,
13
+ message: err.message,
14
+ status,
15
+ ...(err.field ? { field: err.field } : {}),
16
+ ...(err.fields ? { fields: err.fields } : {}),
17
+ },
18
+ };
19
+ }
20
+
21
+ const status = typeof err?.status === 'number' ? err.status : 500;
22
+ return {
23
+ error: {
24
+ code: err?.code || 'INTERNAL_ERROR',
25
+ message: err?.message || 'Internal server error',
26
+ status,
27
+ },
28
+ };
29
+ }
package/src/handler.js ADDED
@@ -0,0 +1,157 @@
1
+ import { setCookieHeader } from './cookies.js';
2
+ import { normalizeVoultError } from './errors.js';
3
+ import { oauthRoutes } from './oauth.js';
4
+ import { authRoutes, json } from './routes.js';
5
+ import { getSessionFromRequest, renewSession, sessionCookies } from './session.js';
6
+
7
+ /**
8
+ * @typedef {import('./index.js').VoultConfig} VoultConfig
9
+ * @typedef {import('./session.js').CookieToSet} CookieToSet
10
+ * @typedef {{ status: number, body?: unknown, location?: string }} Result
11
+ * @typedef {{
12
+ * request: Request,
13
+ * url: URL,
14
+ * basePath: string,
15
+ * params: Record<string, string>,
16
+ * query: (name: string) => string | undefined,
17
+ * body: Record<string, any>,
18
+ * config: VoultConfig,
19
+ * client: import('@voult/sdk').VoultClient,
20
+ * incoming: { accessToken: string | null, refreshToken: string | null },
21
+ * read: (name: string) => unknown,
22
+ * cookies: CookieToSet[],
23
+ * renew: () => Promise<void>,
24
+ * }} Context
25
+ * @typedef {[method: string, path: string, handler: (ctx: Context) => Promise<Result>]} Route
26
+ */
27
+
28
+ const MAX_BODY_BYTES = 100 * 1024; // express.json()'s default limit
29
+
30
+ /** Express-style route matching: `:param` segments, case-insensitive, optional trailing slash. */
31
+ function compile([method, path, handler]) {
32
+ const keys = [];
33
+ const source = path.replace(/:(\w+)/g, (_, key) => {
34
+ keys.push(key);
35
+ return '([^/]+)';
36
+ });
37
+ return { method, pattern: new RegExp(`^${source}/?$`, 'i'), keys, handler };
38
+ }
39
+
40
+ function match(routes, method, path) {
41
+ for (const route of routes) {
42
+ if (route.method !== method) continue;
43
+ const found = route.pattern.exec(path);
44
+ if (found) {
45
+ const params = Object.fromEntries(route.keys.map((key, i) => [key, decodeURIComponent(found[i + 1])]));
46
+ return { handler: route.handler, params };
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** The request path below `basePath`, or null when the request isn't for this router. */
53
+ function routePath(pathname, basePath) {
54
+ if (!basePath) return pathname;
55
+ if (pathname.toLowerCase() === basePath.toLowerCase()) return '/';
56
+ return pathname.toLowerCase().startsWith(`${basePath.toLowerCase()}/`) ? pathname.slice(basePath.length) : null;
57
+ }
58
+
59
+ /** JSON bodies only, like express.json(); anything else is `{}`. */
60
+ async function readBody(request) {
61
+ if (request.method === 'GET' || request.method === 'HEAD') return {};
62
+ if (!/\bjson\b/i.test(request.headers.get('content-type') ?? '')) return {};
63
+ const text = await request.text();
64
+ if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) {
65
+ throw Object.assign(new Error('Request body is too large'), { status: 413, code: 'PAYLOAD_TOO_LARGE' });
66
+ }
67
+ if (!text.trim()) return {};
68
+ try {
69
+ const body = JSON.parse(text);
70
+ return body && typeof body === 'object' ? body : {};
71
+ } catch {
72
+ throw Object.assign(new Error('Request body is not valid JSON'), { status: 400, code: 'INVALID_JSON' });
73
+ }
74
+ }
75
+
76
+ // Express's res.location() encoding (the `encodeurl` package): keep valid URL characters and
77
+ // %-escapes, encode the rest, so a Location header is always valid ASCII.
78
+ const ENCODE_CHARS = /(?:[^\x21\x23-\x3B\x3D\x3F-\x5F\x61-\x7A\x7C\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g;
79
+ const UNMATCHED_SURROGATES = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g;
80
+ const encodeLocation = (url) => String(url).replace(UNMATCHED_SURROGATES, '$1�$2').replace(ENCODE_CHARS, encodeURI);
81
+
82
+ /** @param {Result} result @param {string[]} setCookies */
83
+ function toResponse(result, setCookies) {
84
+ const headers = new Headers();
85
+ for (const cookie of setCookies) headers.append('Set-Cookie', cookie);
86
+ if (result.location !== undefined) {
87
+ headers.set('Location', encodeLocation(result.location));
88
+ return new Response(null, { status: result.status, headers });
89
+ }
90
+ headers.set('Content-Type', 'application/json; charset=utf-8');
91
+ return new Response(JSON.stringify(result.body), { status: result.status, headers });
92
+ }
93
+
94
+ const defaultNotFound = () => Response.json(
95
+ { error: { code: 'NOT_FOUND', message: 'No such Voult route', status: 404 } },
96
+ { status: 404 }
97
+ );
98
+
99
+ /**
100
+ * The Voult router as one function: `handle(request, { basePath }) → Response`.
101
+ * Holds every auth route, the cookies, the OAuth nonce and session renewal; adapters only
102
+ * translate their framework's request/response to and from the Fetch API.
103
+ *
104
+ * @param {VoultConfig} config A normalized config (`resolveConfig` / `loadConfigFromEnv`).
105
+ */
106
+ export function createVoultHandler(config) {
107
+ const routes = [...authRoutes, ...oauthRoutes()].map(compile);
108
+
109
+ /**
110
+ * @param {Request} request
111
+ * @param {{ basePath?: string, notFound?: (request: Request) => Response | null }} [options]
112
+ * basePath: where the router is mounted (`/api/auth`). notFound: answer for paths with no
113
+ * route (default 404 JSON; return null to let the adapter fall through).
114
+ * @returns {Promise<Response | null>}
115
+ */
116
+ return async function handle(request, { basePath = '', notFound = defaultNotFound } = {}) {
117
+ const url = new URL(request.url);
118
+ const path = routePath(url.pathname, basePath.replace(/\/$/, ''));
119
+ const method = request.method === 'HEAD' ? 'GET' : request.method;
120
+ const found = path === null ? null : match(routes, method, path);
121
+ if (!found) return notFound(request);
122
+
123
+ /** @type {Context | undefined} */
124
+ let ctx;
125
+ /** @type {Result} */
126
+ let result;
127
+ try {
128
+ const body = await readBody(request);
129
+ const { client, incoming, read } = await getSessionFromRequest(request, config, { body });
130
+ ctx = {
131
+ request,
132
+ url,
133
+ basePath: basePath.replace(/\/$/, ''),
134
+ params: found.params,
135
+ query: (name) => url.searchParams.get(name) ?? undefined,
136
+ body,
137
+ config,
138
+ client,
139
+ incoming,
140
+ read,
141
+ cookies: [],
142
+ renew: async () => { ctx.cookies.push(...await renewSession(client, read, config)); },
143
+ };
144
+ result = await found.handler(ctx);
145
+ } catch (err) {
146
+ const payload = normalizeVoultError(err);
147
+ result = json(payload, payload.error.status);
148
+ }
149
+
150
+ // The handler's own cookies first, then the session as it stands now (like Express, where
151
+ // the session cookies are written as the response goes out).
152
+ const cookies = ctx ? [...ctx.cookies, ...sessionCookies(ctx.client, ctx.incoming, config)] : [];
153
+ const setCookies = await Promise.all(cookies.map((cookie) => setCookieHeader(cookie, config.sessionSecret)));
154
+ const response = toResponse(result, setCookies);
155
+ return request.method === 'HEAD' ? new Response(null, response) : response;
156
+ };
157
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ /** The per-request `@voult/sdk` VoultClient (the parts adapters use). */
2
+ export interface VoultClient {
3
+ accessToken: string | null;
4
+ refreshToken: string | null;
5
+ user: unknown;
6
+ isAuthenticated(): boolean;
7
+ getCurrentUser(): unknown;
8
+ setSession(user: unknown, accessToken: string, refreshToken?: string | null): void;
9
+ clearSession(): void;
10
+ }
11
+
12
+ export type SessionStrategy = 'cookie' | 'bearer';
13
+
14
+ /** Where hosted OAuth sends the browser, relative to `appUrl`. Each must start with "/". */
15
+ export interface VoultOAuthPaths {
16
+ /** Default `/`. After sign-in (unless `returnTo` was given) or linking. */
17
+ successPath?: string;
18
+ /** Default `/mfa`. User has MFA: prompt for a code and POST /mfa/verify { mfaToken }. */
19
+ mfaPath?: string;
20
+ /** Default `/login`. Receives `?voult_error=<CODE>&voult_error_description=<text>`. */
21
+ errorPath?: string;
22
+ }
23
+
24
+ export interface VoultConfig {
25
+ baseURL?: string;
26
+ clientId: string;
27
+ clientSecret: string;
28
+ sessionSecret?: string;
29
+ appUrl?: string;
30
+ /** Override the computed hosted-OAuth callback URL (e.g. behind a proxy). */
31
+ oauthCallbackUrl?: string;
32
+ oauth?: VoultOAuthPaths;
33
+ session?: { strategy?: SessionStrategy };
34
+ }
35
+
36
+ export interface LoadConfigFromEnvOptions {
37
+ env?: Record<string, string | undefined>;
38
+ overrides?: Partial<VoultConfig>;
39
+ }
40
+
41
+ export interface ResolveConfigOptions extends LoadConfigFromEnvOptions {
42
+ config?: VoultConfig;
43
+ oauth?: VoultOAuthPaths;
44
+ }
45
+
46
+ export interface CookieToSet {
47
+ name: string;
48
+ value: string;
49
+ options: Record<string, unknown>;
50
+ }
51
+
52
+ export type ReadCookie = (name: string) => unknown;
53
+
54
+ export interface IncomingSession {
55
+ accessToken: string | null;
56
+ refreshToken: string | null;
57
+ }
58
+
59
+ export interface HandleOptions {
60
+ /** Where the router is mounted, e.g. `/api/auth`. */
61
+ basePath?: string;
62
+ /** Answer for paths with no route. Default: 404 JSON. Return null to fall through. */
63
+ notFound?: (request: Request) => Response | null;
64
+ }
65
+
66
+ export type VoultHandler = (request: Request, options?: HandleOptions) => Promise<Response | null>;
67
+
68
+ export function createVoultHandler(config: VoultConfig): VoultHandler;
69
+ export function loadConfigFromEnv(options?: LoadConfigFromEnvOptions): VoultConfig;
70
+ export function resolveConfig(options?: ResolveConfigOptions): VoultConfig;
71
+ export function normalizeVoultError(err: unknown): { error: { code: string; message: string; status: number; field?: string; fields?: unknown } };
72
+
73
+ export const COOKIE_ACCESS: 'voult_access';
74
+ export const COOKIE_REFRESH: 'voult_refresh';
75
+ export const COOKIE_USER: 'voult_user';
76
+ export const COOKIE_OAUTH: 'voult_oauth';
77
+ export const COOKIE_MFA_PENDING: 'voult_mfa_pending';
78
+
79
+ export function createVoultClient(config: VoultConfig): VoultClient;
80
+ export function getSessionFromRequest(
81
+ request: Request,
82
+ config: VoultConfig,
83
+ options?: { body?: Record<string, unknown> }
84
+ ): Promise<{ client: VoultClient; incoming: IncomingSession; read: ReadCookie }>;
85
+ export function restoreSession(
86
+ client: VoultClient,
87
+ incoming: { read: ReadCookie; authorization?: string | null; refreshToken?: unknown },
88
+ config: VoultConfig
89
+ ): IncomingSession;
90
+ export function sessionCookies(client: VoultClient, incoming: IncomingSession, config: VoultConfig): CookieToSet[];
91
+ export function renewSession(client: VoultClient, read: ReadCookie, config: VoultConfig): Promise<CookieToSet[]>;
92
+
93
+ export function readCookieHeader(header: string | null | undefined, secret?: string): Promise<{
94
+ cookies: Record<string, string>;
95
+ signedCookies: Record<string, string | false>;
96
+ }>;
97
+ export function serializeCookie(name: string, value: string, options?: Record<string, unknown>): string;
98
+ export function setCookieHeader(cookie: CookieToSet, secret?: string): Promise<string>;
99
+ export function sign(value: string, secret: string): Promise<string>;
100
+ export function unsign(signed: string, secret: string): Promise<string | false>;
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // @voult/core: the Voult router on the Fetch API, for adapters (@voult/express, @voult/next).
2
+ // Apps install an adapter, not this package.
3
+ export { createVoultHandler } from './handler.js';
4
+ export { loadConfigFromEnv, resolveConfig } from './config.js';
5
+ export { normalizeVoultError } from './errors.js';
6
+ export {
7
+ COOKIE_ACCESS,
8
+ COOKIE_MFA_PENDING,
9
+ COOKIE_OAUTH,
10
+ COOKIE_REFRESH,
11
+ COOKIE_USER,
12
+ createVoultClient,
13
+ getSessionFromRequest,
14
+ renewSession,
15
+ restoreSession,
16
+ sessionCookies,
17
+ } from './session.js';
18
+ export { readCookieHeader, serializeCookie, setCookieHeader, sign, unsign } from './cookies.js';
package/src/oauth.js ADDED
@@ -0,0 +1,199 @@
1
+ import {
2
+ exchangeOAuthCode,
3
+ getAppInfo,
4
+ getOAuthAuthorizationUrl,
5
+ linkOAuthProvider,
6
+ } from '@voult/sdk';
7
+ import { base64 } from './cookies.js';
8
+ import { json, redirect } from './routes.js';
9
+ import {
10
+ COOKIE_MFA_PENDING,
11
+ COOKIE_OAUTH,
12
+ MFA_PENDING_MAX_AGE_MS,
13
+ OAUTH_COOKIE_MAX_AGE_MS,
14
+ clearCookie,
15
+ setCookie,
16
+ } from './session.js';
17
+
18
+ // Hosted OAuth: provider credentials live in the Voult dashboard, never in this server's env.
19
+ // GET /oauth/providers → which providers the app can use
20
+ // GET /oauth/:provider/start → 302 to the provider (via Voult)
21
+ // GET /oauth/callback → Voult sends the browser back here; we set the session
22
+
23
+ /**
24
+ * @typedef {import('./handler.js').Context} Context
25
+ * @typedef {import('./handler.js').Route} Route
26
+ * @typedef {import('./index.js').VoultConfig} VoultConfig
27
+ */
28
+
29
+ const PROVIDERS_CACHE_MS = 60 * 1000;
30
+ const INTENTS = ['authenticate', 'login', 'register', 'link'];
31
+
32
+ /**
33
+ * Only same-site relative paths, so `returnTo` can't send users to another site.
34
+ * @param {unknown} value
35
+ * @param {string} fallback
36
+ */
37
+ export function safeReturnTo(value, fallback) {
38
+ return typeof value === 'string'
39
+ && value.length <= 2048
40
+ && value.startsWith('/')
41
+ && !value.startsWith('//')
42
+ && !value.includes('\\')
43
+ ? value
44
+ : fallback;
45
+ }
46
+
47
+ /**
48
+ * A browser URL on the integrator's frontend (VOULT_APP_URL), or same-origin when unset.
49
+ * @param {VoultConfig} config
50
+ * @param {string} path
51
+ * @param {Record<string, string | undefined>} [params]
52
+ */
53
+ function appLocation(config, path, params = {}) {
54
+ const query = new URLSearchParams(
55
+ Object.entries(params).filter(([, value]) => value != null && value !== '')
56
+ ).toString();
57
+ const base = config.appUrl ? config.appUrl.replace(/\/$/, '') : '';
58
+ const joiner = path.includes('?') ? '&' : '?';
59
+ return `${base}${path}${query ? `${joiner}${query}` : ''}`;
60
+ }
61
+
62
+ /**
63
+ * The URL Voult sends the browser back to. Must be on the app's callback allowlist.
64
+ * Behind a proxy, let the adapter see the public URL (Express: `app.set('trust proxy', 1)`)
65
+ * or set VOULT_OAUTH_CALLBACK_URL.
66
+ * @param {Context} ctx
67
+ */
68
+ export function oauthCallbackUrl(ctx) {
69
+ return ctx.config.oauthCallbackUrl || `${ctx.url.protocol}//${ctx.url.host}${ctx.basePath}/oauth/callback`;
70
+ }
71
+
72
+ function redirectWithError(config, code, description) {
73
+ return redirect(appLocation(config, config.oauth.errorPath, {
74
+ voult_error: code,
75
+ voult_error_description: description,
76
+ }));
77
+ }
78
+
79
+ function errorCode(err) {
80
+ return err?.apiCode || err?.code || 'OAUTH_FAILED';
81
+ }
82
+
83
+ function newNonce() {
84
+ return base64(globalThis.crypto.getRandomValues(new Uint8Array(24)))
85
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
86
+ }
87
+
88
+ function readOAuthCookie(ctx) {
89
+ const raw = ctx.read(COOKIE_OAUTH);
90
+ if (typeof raw !== 'string') return null;
91
+ try {
92
+ return JSON.parse(raw);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** One provider cache per handler. @returns {Route[]} */
99
+ export function oauthRoutes() {
100
+ let providersCache = { at: 0, value: null };
101
+
102
+ return [
103
+ ['GET', '/oauth/providers', async (ctx) => {
104
+ if (!providersCache.value || Date.now() - providersCache.at > PROVIDERS_CACHE_MS) {
105
+ const info = await getAppInfo(ctx.client);
106
+ const providers = Object.fromEntries(
107
+ Object.entries(info.providers || {}).map(([name, s]) => [name, Boolean(s.enabled && s.configured)])
108
+ );
109
+ providersCache = { at: Date.now(), value: { providers } };
110
+ }
111
+ return json(providersCache.value);
112
+ }],
113
+
114
+ ['GET', '/oauth/:provider/start', async (ctx) => {
115
+ const { config, client } = ctx;
116
+ if (config.session.strategy !== 'cookie') {
117
+ return json({
118
+ error: {
119
+ code: 'OAUTH_REQUIRES_COOKIE_SESSION',
120
+ message: 'Hosted OAuth sets the session in a cookie. Use VOULT_SESSION_STRATEGY=cookie, or call the @voult/sdk OAuth functions yourself.',
121
+ status: 400,
122
+ },
123
+ }, 400);
124
+ }
125
+
126
+ const provider = String(ctx.params.provider).toLowerCase();
127
+ const intent = ctx.query('intent') ?? 'authenticate';
128
+ if (!INTENTS.includes(intent)) {
129
+ return redirectWithError(config, 'INVALID_INTENT', `intent must be one of: ${INTENTS.join(', ')}`);
130
+ }
131
+ if (intent === 'link' && !client.accessToken) {
132
+ return redirectWithError(config, 'LOGIN_REQUIRED', 'Sign in before linking another account.');
133
+ }
134
+
135
+ const nonce = newNonce();
136
+ const redirectUri = oauthCallbackUrl(ctx);
137
+ const returnTo = safeReturnTo(ctx.query('returnTo'), config.oauth.successPath);
138
+
139
+ let authUrl;
140
+ try {
141
+ ({ authUrl } = intent === 'link'
142
+ ? await linkOAuthProvider(provider, { redirectUri, state: nonce }, client)
143
+ : await getOAuthAuthorizationUrl(provider, { intent, redirectUri, state: nonce }, client));
144
+ } catch (err) {
145
+ return redirectWithError(config, errorCode(err), err.message);
146
+ }
147
+
148
+ // Ties the callback to this browser: Voult echoes `state`, we compare it to this cookie.
149
+ ctx.cookies.push(setCookie(
150
+ config,
151
+ COOKIE_OAUTH,
152
+ JSON.stringify({ nonce, provider, intent, returnTo, redirectUri }),
153
+ { maxAge: OAUTH_COOKIE_MAX_AGE_MS }
154
+ ));
155
+ return redirect(authUrl);
156
+ }],
157
+
158
+ ['GET', '/oauth/callback', async (ctx) => {
159
+ const { config, client } = ctx;
160
+ const saved = readOAuthCookie(ctx);
161
+ ctx.cookies.push(clearCookie(config, COOKIE_OAUTH));
162
+
163
+ if (!saved?.nonce || ctx.query('state') !== saved.nonce) {
164
+ return redirectWithError(
165
+ config, 'INVALID_OAUTH_STATE',
166
+ 'This sign-in expired or was started in another browser. Please try again.'
167
+ );
168
+ }
169
+
170
+ if (ctx.query('error')) {
171
+ return redirectWithError(config, ctx.query('error'), ctx.query('error_description'));
172
+ }
173
+
174
+ if (ctx.query('linked') === '1') {
175
+ return redirect(appLocation(config, saved.returnTo, { voult_linked: saved.provider }));
176
+ }
177
+
178
+ const voultCode = ctx.query('voult_code');
179
+ if (typeof voultCode !== 'string') {
180
+ return redirectWithError(config, 'MISSING_OAUTH_CODE', 'Voult did not return a sign-in code.');
181
+ }
182
+
183
+ let result;
184
+ try {
185
+ result = await exchangeOAuthCode(voultCode, { redirectUri: saved.redirectUri }, client);
186
+ } catch (err) {
187
+ return redirectWithError(config, errorCode(err), err.message);
188
+ }
189
+
190
+ if (result.mfaRequired) {
191
+ ctx.cookies.push(setCookie(config, COOKIE_MFA_PENDING, result.mfaPendingToken, { maxAge: MFA_PENDING_MAX_AGE_MS }));
192
+ return redirect(appLocation(config, config.oauth.mfaPath));
193
+ }
194
+
195
+ // The session cookies go out with this redirect (sessionCookies, in the handler).
196
+ return redirect(appLocation(config, saved.returnTo));
197
+ }],
198
+ ];
199
+ }
package/src/routes.js ADDED
@@ -0,0 +1,134 @@
1
+ import {
2
+ AuthenticationError,
3
+ getCurrentUser,
4
+ getMfaStatus,
5
+ refreshSession,
6
+ resetPassword,
7
+ sendPasswordResetEmail,
8
+ signInWithEmailAndPassword,
9
+ signInWithUsernameAndPassword,
10
+ signOut,
11
+ signUpWithEmailAndPassword,
12
+ signUpWithUsernameAndPassword,
13
+ updateProfile,
14
+ verifyEmail,
15
+ verifyMfaLogin,
16
+ } from '@voult/sdk';
17
+ import { normalizeVoultError } from './errors.js';
18
+ import { COOKIE_MFA_PENDING, MFA_PENDING_MAX_AGE_MS, clearCookie, setCookie, toPublicAuthResult } from './session.js';
19
+
20
+ /**
21
+ * @typedef {import('./handler.js').Context} Context
22
+ * @typedef {import('./handler.js').Route} Route
23
+ */
24
+
25
+ /** @param {unknown} body @param {number} [status] */
26
+ export const json = (body, status = 200) => ({ status, body });
27
+ /** @param {string} location */
28
+ export const redirect = (location) => ({ status: 302, location });
29
+
30
+ /**
31
+ * Only signed-in users get through: renews an expired access cookie from the refresh cookie
32
+ * first, then answers 401 JSON.
33
+ * @param {(ctx: Context) => Promise<unknown>} handler
34
+ */
35
+ export function authenticated(handler) {
36
+ return async (ctx) => {
37
+ await ctx.renew();
38
+ if (!ctx.client.accessToken) {
39
+ const payload = normalizeVoultError(new AuthenticationError('Authentication required'));
40
+ return json(payload, payload.error.status);
41
+ }
42
+ return handler(ctx);
43
+ };
44
+ }
45
+
46
+ /** @param {Context} ctx @param {object} result */
47
+ function authResult(ctx, result) {
48
+ // Park the MFA pending token server-side too (as hosted OAuth does), so /mfa/verify needs only
49
+ // the code and a page reload doesn't lose the sign-in. The JSON still carries it for older clients.
50
+ if (result?.mfaRequired && result.mfaPendingToken && ctx.config.session.strategy === 'cookie') {
51
+ ctx.cookies.push(setCookie(ctx.config, COOKIE_MFA_PENDING, result.mfaPendingToken, { maxAge: MFA_PENDING_MAX_AGE_MS }));
52
+ }
53
+ return json(toPublicAuthResult(result, ctx.config));
54
+ }
55
+
56
+ /** @type {Route[]} */
57
+ export const authRoutes = [
58
+ ['GET', '/session', async (ctx) => {
59
+ await ctx.renew();
60
+
61
+ if (!ctx.client.accessToken) {
62
+ // A sign-in that stopped at MFA: the page should show the code prompt.
63
+ const mfaPending = Boolean(ctx.read(COOKIE_MFA_PENDING));
64
+ return json({ authenticated: false, user: null, ...(mfaPending && { mfaPending: true }) });
65
+ }
66
+
67
+ const localUser = ctx.client.getCurrentUser();
68
+ if (localUser?.id || localUser?.email) {
69
+ return json({ authenticated: true, user: localUser });
70
+ }
71
+
72
+ try {
73
+ return json({ authenticated: true, user: await getCurrentUser(ctx.client) });
74
+ } catch {
75
+ return json({ authenticated: false, user: null });
76
+ }
77
+ }],
78
+
79
+ ['POST', '/register', async (ctx) => {
80
+ const { email, password, fullName, username } = ctx.body;
81
+ return authResult(ctx, await signUpWithEmailAndPassword(email, password, { fullName, username }, ctx.client));
82
+ }],
83
+
84
+ ['POST', '/username-register', async (ctx) => {
85
+ const { username, password, fullName, email } = ctx.body;
86
+ return authResult(ctx, await signUpWithUsernameAndPassword(username, password, { fullName, email }, ctx.client));
87
+ }],
88
+
89
+ ['POST', '/email-login', async (ctx) => {
90
+ const { email, password } = ctx.body;
91
+ return authResult(ctx, await signInWithEmailAndPassword(email, password, ctx.client));
92
+ }],
93
+
94
+ ['POST', '/username-login', async (ctx) => {
95
+ const { username, password } = ctx.body;
96
+ return authResult(ctx, await signInWithUsernameAndPassword(username, password, ctx.client));
97
+ }],
98
+
99
+ ['POST', '/logout', async (ctx) => json(await signOut(ctx.client))],
100
+
101
+ ['POST', '/sessions/refresh', async (ctx) => {
102
+ if (ctx.body.refreshToken) {
103
+ ctx.client.refreshToken = ctx.body.refreshToken;
104
+ }
105
+ return authResult(ctx, await refreshSession(ctx.client));
106
+ }],
107
+
108
+ ['GET', '/user/me', authenticated(async (ctx) => json({ user: await getCurrentUser(ctx.client) }))],
109
+
110
+ ['PATCH', '/user/me', authenticated(async (ctx) => json(await updateProfile(ctx.body, ctx.client)))],
111
+
112
+ ['POST', '/user/forgot-password', async (ctx) => json(await sendPasswordResetEmail(ctx.body.email, ctx.client))],
113
+
114
+ ['POST', '/user/reset-password', async (ctx) => {
115
+ const { token, appId } = ctx.body;
116
+ const newPassword = ctx.body.newPassword ?? ctx.body.password;
117
+ return json(await resetPassword(token, newPassword, { appId }, ctx.client));
118
+ }],
119
+
120
+ ['GET', '/user/verify-email', async (ctx) => (
121
+ json(await verifyEmail(ctx.query('token'), { appId: ctx.query('appId') }, ctx.client))
122
+ )],
123
+
124
+ ['POST', '/mfa/verify', async (ctx) => {
125
+ const { mfaToken } = ctx.body;
126
+ // Password sign-in hands the token to the page; hosted OAuth keeps it in a cookie.
127
+ const mfaPendingToken = ctx.body.mfaPendingToken ?? ctx.read(COOKIE_MFA_PENDING);
128
+ const result = await verifyMfaLogin(mfaPendingToken, mfaToken, ctx.client);
129
+ ctx.cookies.push(clearCookie(ctx.config, COOKIE_MFA_PENDING));
130
+ return authResult(ctx, result);
131
+ }],
132
+
133
+ ['GET', '/mfa/status', authenticated(async (ctx) => json(await getMfaStatus(ctx.client)))],
134
+ ];
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
+ }