@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@voult/core` are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
The router now serves every end-user feature, so apps write no auth route handlers. New routes (below the mount path):
|
|
8
|
+
|
|
9
|
+
- **Session required** (renews from the refresh cookie first, else `401` JSON): `POST /mfa/setup`, `POST /mfa/enable { code }`, `POST /mfa/disable { code, password? }` (no password needed for Google/GitHub accounts), `POST /mfa/backup-codes { code }`, `GET /sessions` (this browser's session has `isCurrent: true`), `DELETE /sessions/:id` (revoking your own session signs this browser out; the answer says `current: true`), `GET /oauth/linked`, `DELETE /oauth/linked/:provider`, `DELETE /user/me` (Voult disables the account; this browser is signed out), `POST /passkeys/register/options`, `POST /passkeys/register/verify`, `GET /passkeys`, `PATCH /passkeys/:id`, `DELETE /passkeys/:id`.
|
|
10
|
+
- **Public:** `POST /mfa/cancel` (drop a sign-in waiting for its code), `POST /passkeys/login/options`, `POST /passkeys/login/verify` (sets the session, or the MFA-pending cookie, like password sign-in), `POST /magic-link { email, returnTo? }` (answers `{ sent: true }` whether or not the email has an account), `GET /magic-link/verify?token=` (sets the session or MFA-pending cookie and redirects like the OAuth callback: `returnTo`, `mfaPath`, or `errorPath?voult_error=…`).
|
|
11
|
+
- Magic links point at `/magic-link/verify` next to the OAuth callback, so the one callback URL on the app's allowlist covers both.
|
|
12
|
+
- Needs Voult API 1.2+ for `isCurrent` (older APIs: `isCurrent` is missing and revoking your own session doesn't sign this browser out) and for MFA on magic links.
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
First release. The Voult router as one function on the Fetch API, so adapters only translate requests:
|
|
17
|
+
|
|
18
|
+
- `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.
|
|
19
|
+
- Cookies are byte-compatible with Express + `cookie-parser` (`s:` + HMAC-SHA256 signatures), signed with WebCrypto.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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
|
-
|
|
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
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@voult/core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The Voult auth router on the Fetch API (Request \u2192 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,158 @@
|
|
|
1
|
+
import { setCookieHeader } from './cookies.js';
|
|
2
|
+
import { normalizeVoultError } from './errors.js';
|
|
3
|
+
import { oauthRoutes } from './oauth.js';
|
|
4
|
+
import { passwordlessRoutes } from './passwordless.js';
|
|
5
|
+
import { authRoutes, json } from './routes.js';
|
|
6
|
+
import { getSessionFromRequest, renewSession, sessionCookies } from './session.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import('./index.js').VoultConfig} VoultConfig
|
|
10
|
+
* @typedef {import('./session.js').CookieToSet} CookieToSet
|
|
11
|
+
* @typedef {{ status: number, body?: unknown, location?: string }} Result
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* request: Request,
|
|
14
|
+
* url: URL,
|
|
15
|
+
* basePath: string,
|
|
16
|
+
* params: Record<string, string>,
|
|
17
|
+
* query: (name: string) => string | undefined,
|
|
18
|
+
* body: Record<string, any>,
|
|
19
|
+
* config: VoultConfig,
|
|
20
|
+
* client: import('@voult/sdk').VoultClient,
|
|
21
|
+
* incoming: { accessToken: string | null, refreshToken: string | null },
|
|
22
|
+
* read: (name: string) => unknown,
|
|
23
|
+
* cookies: CookieToSet[],
|
|
24
|
+
* renew: () => Promise<void>,
|
|
25
|
+
* }} Context
|
|
26
|
+
* @typedef {[method: string, path: string, handler: (ctx: Context) => Promise<Result>]} Route
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const MAX_BODY_BYTES = 100 * 1024; // express.json()'s default limit
|
|
30
|
+
|
|
31
|
+
/** Express-style route matching: `:param` segments, case-insensitive, optional trailing slash. */
|
|
32
|
+
function compile([method, path, handler]) {
|
|
33
|
+
const keys = [];
|
|
34
|
+
const source = path.replace(/:(\w+)/g, (_, key) => {
|
|
35
|
+
keys.push(key);
|
|
36
|
+
return '([^/]+)';
|
|
37
|
+
});
|
|
38
|
+
return { method, pattern: new RegExp(`^${source}/?$`, 'i'), keys, handler };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function match(routes, method, path) {
|
|
42
|
+
for (const route of routes) {
|
|
43
|
+
if (route.method !== method) continue;
|
|
44
|
+
const found = route.pattern.exec(path);
|
|
45
|
+
if (found) {
|
|
46
|
+
const params = Object.fromEntries(route.keys.map((key, i) => [key, decodeURIComponent(found[i + 1])]));
|
|
47
|
+
return { handler: route.handler, params };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The request path below `basePath`, or null when the request isn't for this router. */
|
|
54
|
+
function routePath(pathname, basePath) {
|
|
55
|
+
if (!basePath) return pathname;
|
|
56
|
+
if (pathname.toLowerCase() === basePath.toLowerCase()) return '/';
|
|
57
|
+
return pathname.toLowerCase().startsWith(`${basePath.toLowerCase()}/`) ? pathname.slice(basePath.length) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** JSON bodies only, like express.json(); anything else is `{}`. */
|
|
61
|
+
async function readBody(request) {
|
|
62
|
+
if (request.method === 'GET' || request.method === 'HEAD') return {};
|
|
63
|
+
if (!/\bjson\b/i.test(request.headers.get('content-type') ?? '')) return {};
|
|
64
|
+
const text = await request.text();
|
|
65
|
+
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) {
|
|
66
|
+
throw Object.assign(new Error('Request body is too large'), { status: 413, code: 'PAYLOAD_TOO_LARGE' });
|
|
67
|
+
}
|
|
68
|
+
if (!text.trim()) return {};
|
|
69
|
+
try {
|
|
70
|
+
const body = JSON.parse(text);
|
|
71
|
+
return body && typeof body === 'object' ? body : {};
|
|
72
|
+
} catch {
|
|
73
|
+
throw Object.assign(new Error('Request body is not valid JSON'), { status: 400, code: 'INVALID_JSON' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Express's res.location() encoding (the `encodeurl` package): keep valid URL characters and
|
|
78
|
+
// %-escapes, encode the rest, so a Location header is always valid ASCII.
|
|
79
|
+
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;
|
|
80
|
+
const UNMATCHED_SURROGATES = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g;
|
|
81
|
+
const encodeLocation = (url) => String(url).replace(UNMATCHED_SURROGATES, '$1�$2').replace(ENCODE_CHARS, encodeURI);
|
|
82
|
+
|
|
83
|
+
/** @param {Result} result @param {string[]} setCookies */
|
|
84
|
+
function toResponse(result, setCookies) {
|
|
85
|
+
const headers = new Headers();
|
|
86
|
+
for (const cookie of setCookies) headers.append('Set-Cookie', cookie);
|
|
87
|
+
if (result.location !== undefined) {
|
|
88
|
+
headers.set('Location', encodeLocation(result.location));
|
|
89
|
+
return new Response(null, { status: result.status, headers });
|
|
90
|
+
}
|
|
91
|
+
headers.set('Content-Type', 'application/json; charset=utf-8');
|
|
92
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const defaultNotFound = () => Response.json(
|
|
96
|
+
{ error: { code: 'NOT_FOUND', message: 'No such Voult route', status: 404 } },
|
|
97
|
+
{ status: 404 }
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Voult router as one function: `handle(request, { basePath }) → Response`.
|
|
102
|
+
* Holds every auth route, the cookies, the OAuth nonce and session renewal; adapters only
|
|
103
|
+
* translate their framework's request/response to and from the Fetch API.
|
|
104
|
+
*
|
|
105
|
+
* @param {VoultConfig} config A normalized config (`resolveConfig` / `loadConfigFromEnv`).
|
|
106
|
+
*/
|
|
107
|
+
export function createVoultHandler(config) {
|
|
108
|
+
const routes = [...authRoutes, ...passwordlessRoutes, ...oauthRoutes()].map(compile);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Request} request
|
|
112
|
+
* @param {{ basePath?: string, notFound?: (request: Request) => Response | null }} [options]
|
|
113
|
+
* basePath: where the router is mounted (`/api/auth`). notFound: answer for paths with no
|
|
114
|
+
* route (default 404 JSON; return null to let the adapter fall through).
|
|
115
|
+
* @returns {Promise<Response | null>}
|
|
116
|
+
*/
|
|
117
|
+
return async function handle(request, { basePath = '', notFound = defaultNotFound } = {}) {
|
|
118
|
+
const url = new URL(request.url);
|
|
119
|
+
const path = routePath(url.pathname, basePath.replace(/\/$/, ''));
|
|
120
|
+
const method = request.method === 'HEAD' ? 'GET' : request.method;
|
|
121
|
+
const found = path === null ? null : match(routes, method, path);
|
|
122
|
+
if (!found) return notFound(request);
|
|
123
|
+
|
|
124
|
+
/** @type {Context | undefined} */
|
|
125
|
+
let ctx;
|
|
126
|
+
/** @type {Result} */
|
|
127
|
+
let result;
|
|
128
|
+
try {
|
|
129
|
+
const body = await readBody(request);
|
|
130
|
+
const { client, incoming, read } = await getSessionFromRequest(request, config, { body });
|
|
131
|
+
ctx = {
|
|
132
|
+
request,
|
|
133
|
+
url,
|
|
134
|
+
basePath: basePath.replace(/\/$/, ''),
|
|
135
|
+
params: found.params,
|
|
136
|
+
query: (name) => url.searchParams.get(name) ?? undefined,
|
|
137
|
+
body,
|
|
138
|
+
config,
|
|
139
|
+
client,
|
|
140
|
+
incoming,
|
|
141
|
+
read,
|
|
142
|
+
cookies: [],
|
|
143
|
+
renew: async () => { ctx.cookies.push(...await renewSession(client, read, config)); },
|
|
144
|
+
};
|
|
145
|
+
result = await found.handler(ctx);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const payload = normalizeVoultError(err);
|
|
148
|
+
result = json(payload, payload.error.status);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The handler's own cookies first, then the session as it stands now (like Express, where
|
|
152
|
+
// the session cookies are written as the response goes out).
|
|
153
|
+
const cookies = ctx ? [...ctx.cookies, ...sessionCookies(ctx.client, ctx.incoming, config)] : [];
|
|
154
|
+
const setCookies = await Promise.all(cookies.map((cookie) => setCookieHeader(cookie, config.sessionSecret)));
|
|
155
|
+
const response = toResponse(result, setCookies);
|
|
156
|
+
return request.method === 'HEAD' ? new Response(null, response) : response;
|
|
157
|
+
};
|
|
158
|
+
}
|