@voult/core 0.2.1 → 0.3.1
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 +12 -0
- package/package.json +2 -2
- package/src/errors.js +30 -19
- package/src/handler.js +1 -1
- package/src/index.d.ts +6 -1
- package/src/index.js +1 -1
- package/src/oauth.js +7 -4
- package/src/passwordless.js +14 -6
- package/src/routes.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@voult/core` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.3.1
|
|
6
|
+
|
|
7
|
+
- New route `POST /user/password` (`{ password }`): a user who signed up with Google or GitHub adds a password, so email + password sign-in works too. Checked against the password rule before Voult is called.
|
|
8
|
+
- New route `GET /user/activity?limit=20&skip=0`: the signed-in user's own audit log (sign-ins, MFA and password changes, …), newest first.
|
|
9
|
+
- `@voult/express` and `@voult/next` get both with a fresh install (they depend on `^0.3.0`).
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
- **Breaking: error codes are the Voult API's.** A failed request now answers with the API's catalog code (`INVALID_CREDENTIALS`, `USER_EXISTS`, `WEAK_PASSWORD`, see ERRORS.md) instead of the SDK's category (`AUTHENTICATION_ERROR`, `CONFLICT_ERROR`, `VALIDATION_ERROR`). Errors raised by the router itself keep their codes (`AUTHENTICATION_ERROR` for "Authentication required", `INVALID_JSON`, `PAYLOAD_TOO_LARGE`). If you branch on `code`, switch to the catalog codes; branching on `status` is unaffected.
|
|
14
|
+
- **Internal errors stay on the server.** 5xx, network failures and unexpected errors are logged in full (`console.error`, with the method and path) and reach the browser only as `{ code: 'INTERNAL_ERROR', message: 'Something went wrong. Please try again.' }`. 4xx messages (things the user can fix) are unchanged. The same applies to `?voult_error=` on the OAuth and magic-link error pages. New exports `publicError(err, { log, context })` and `GENERIC_ERROR_MESSAGE`.
|
|
15
|
+
- Passkey routes send the page's origin (the browser's `Origin` header, else the request's own origin) so Voult API 1.4 can use the right passkey domain. Needs `@voult/sdk` 0.2.2.
|
|
16
|
+
|
|
5
17
|
## 0.2.1
|
|
6
18
|
|
|
7
19
|
- New route `GET /session/renew?returnTo=/path`: renews the session from the refresh cookie, saves the new cookies and redirects back (or to `errorPath?voult_error=SESSION_EXPIRED`). For pages rendered where cookies can't be written, such as Next.js Server Components: renewing there would rotate the refresh token without saving it, and Voult's reuse detection would then end the session.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voult/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The Voult auth router on the Fetch API (Request \u2192 Response), shared by @voult/express and @voult/next",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"author": "Samuel Olabode",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@voult/sdk": ">=0.2.
|
|
40
|
+
"@voult/sdk": ">=0.2.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@voult/sdk": "*",
|
package/src/errors.js
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
import { VoultError } from '@voult/sdk';
|
|
2
2
|
|
|
3
|
+
export const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* What the browser may see of an error. 4xx are things the user can fix (wrong password, email
|
|
7
|
+
* taken, locked, too many attempts): their message stays, and the code is the Voult API's own
|
|
8
|
+
* (`INVALID_CREDENTIALS`, as in ERRORS.md) when there is one. Everything else (5xx, network,
|
|
9
|
+
* bugs) is logged in full on the server and reaches the browser only as a generic message.
|
|
5
10
|
* @param {unknown} err
|
|
11
|
+
* @param {{ log?: (...args: unknown[]) => void, context?: string }} [options]
|
|
12
|
+
* @returns {{ code: string, message: string, status: number, field?: string, fields?: unknown }}
|
|
6
13
|
*/
|
|
7
|
-
export function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
status,
|
|
15
|
-
...(err.field ? { field: err.field } : {}),
|
|
16
|
-
...(err.fields ? { fields: err.fields } : {}),
|
|
17
|
-
},
|
|
18
|
-
};
|
|
14
|
+
export function publicError(err, { log = console.error, context = '' } = {}) {
|
|
15
|
+
const isVoult = err instanceof VoultError;
|
|
16
|
+
const status = typeof err?.status === 'number' && err.status >= 400 ? err.status : 500;
|
|
17
|
+
|
|
18
|
+
if (status >= 500) {
|
|
19
|
+
log(`[voult]${context ? ` ${context}` : ''} failed:`, err);
|
|
20
|
+
return { code: 'INTERNAL_ERROR', message: GENERIC_ERROR_MESSAGE, status };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const
|
|
23
|
+
const apiCode = isVoult && err.apiCode && err.apiCode !== 'UNKNOWN_ERROR' ? err.apiCode : undefined;
|
|
22
24
|
return {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
},
|
|
25
|
+
code: apiCode ?? err?.code ?? 'BAD_REQUEST',
|
|
26
|
+
message: err?.message || 'Request failed',
|
|
27
|
+
status,
|
|
28
|
+
...(err?.field ? { field: err.field } : {}),
|
|
29
|
+
...(err?.fields ? { fields: err.fields } : {}),
|
|
28
30
|
};
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The JSON body for an error: `{ error: { code, message, status } }` (see publicError).
|
|
35
|
+
* @param {unknown} err
|
|
36
|
+
* @param {{ log?: (...args: unknown[]) => void, context?: string }} [options]
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeVoultError(err, options) {
|
|
39
|
+
return { error: publicError(err, options) };
|
|
40
|
+
}
|
package/src/handler.js
CHANGED
|
@@ -144,7 +144,7 @@ export function createVoultHandler(config) {
|
|
|
144
144
|
};
|
|
145
145
|
result = await found.handler(ctx);
|
|
146
146
|
} catch (err) {
|
|
147
|
-
const payload = normalizeVoultError(err);
|
|
147
|
+
const payload = normalizeVoultError(err, { context: `${request.method} ${url.pathname}` });
|
|
148
148
|
result = json(payload, payload.error.status);
|
|
149
149
|
}
|
|
150
150
|
|
package/src/index.d.ts
CHANGED
|
@@ -68,7 +68,12 @@ export type VoultHandler = (request: Request, options?: HandleOptions) => Promis
|
|
|
68
68
|
export function createVoultHandler(config: VoultConfig): VoultHandler;
|
|
69
69
|
export function loadConfigFromEnv(options?: LoadConfigFromEnvOptions): VoultConfig;
|
|
70
70
|
export function resolveConfig(options?: ResolveConfigOptions): VoultConfig;
|
|
71
|
-
export
|
|
71
|
+
export interface PublicError { code: string; message: string; status: number; field?: string; fields?: unknown }
|
|
72
|
+
export interface ErrorOptions { log?: (...args: unknown[]) => void; context?: string }
|
|
73
|
+
export const GENERIC_ERROR_MESSAGE: string;
|
|
74
|
+
/** 4xx: message kept, code = the Voult API's (ERRORS.md). 5xx/unexpected: logged on the server, generic for the browser. */
|
|
75
|
+
export function publicError(err: unknown, options?: ErrorOptions): PublicError;
|
|
76
|
+
export function normalizeVoultError(err: unknown, options?: ErrorOptions): { error: PublicError };
|
|
72
77
|
|
|
73
78
|
export const COOKIE_ACCESS: 'voult_access';
|
|
74
79
|
export const COOKIE_REFRESH: 'voult_refresh';
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Apps install an adapter, not this package.
|
|
3
3
|
export { createVoultHandler } from './handler.js';
|
|
4
4
|
export { loadConfigFromEnv, resolveConfig } from './config.js';
|
|
5
|
-
export { normalizeVoultError } from './errors.js';
|
|
5
|
+
export { GENERIC_ERROR_MESSAGE, normalizeVoultError, publicError } from './errors.js';
|
|
6
6
|
export { safeReturnTo } from './oauth.js';
|
|
7
7
|
export {
|
|
8
8
|
COOKIE_ACCESS,
|
package/src/oauth.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
linkOAuthProvider,
|
|
6
6
|
} from '@voult/sdk';
|
|
7
7
|
import { base64 } from './cookies.js';
|
|
8
|
+
import { publicError } from './errors.js';
|
|
8
9
|
import { json, redirect } from './routes.js';
|
|
9
10
|
import {
|
|
10
11
|
COOKIE_MFA_PENDING,
|
|
@@ -76,8 +77,10 @@ export function redirectWithError(config, code, description) {
|
|
|
76
77
|
}));
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
/** A failed step as `?voult_error=<code>&voult_error_description=<message>` (publicError rules). */
|
|
81
|
+
export function redirectWithFailure(config, err, context) {
|
|
82
|
+
const { code, message } = publicError(err, { context });
|
|
83
|
+
return redirectWithError(config, code, message);
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
function newNonce() {
|
|
@@ -155,7 +158,7 @@ export function oauthRoutes() {
|
|
|
155
158
|
? await linkOAuthProvider(provider, { redirectUri, state: nonce }, client)
|
|
156
159
|
: await getOAuthAuthorizationUrl(provider, { intent, redirectUri, state: nonce }, client));
|
|
157
160
|
} catch (err) {
|
|
158
|
-
return
|
|
161
|
+
return redirectWithFailure(config, err, 'OAuth');
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
// Ties the callback to this browser: Voult echoes `state`, we compare it to this cookie.
|
|
@@ -197,7 +200,7 @@ export function oauthRoutes() {
|
|
|
197
200
|
try {
|
|
198
201
|
result = await exchangeOAuthCode(voultCode, { redirectUri: saved.redirectUri }, client);
|
|
199
202
|
} catch (err) {
|
|
200
|
-
return
|
|
203
|
+
return redirectWithFailure(config, err, 'OAuth');
|
|
201
204
|
}
|
|
202
205
|
|
|
203
206
|
if (result.mfaRequired) {
|
package/src/passwordless.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
verifyPasskeyLogin,
|
|
10
10
|
verifyPasskeyRegistration,
|
|
11
11
|
} from '@voult/sdk';
|
|
12
|
-
import { appLocation,
|
|
12
|
+
import { appLocation, oauthCallbackUrl, redirectWithFailure, safeReturnTo } from './oauth.js';
|
|
13
13
|
import { authResult, authenticated, json, redirect } from './routes.js';
|
|
14
14
|
import { COOKIE_MFA_PENDING, MFA_PENDING_MAX_AGE_MS, setCookie } from './session.js';
|
|
15
15
|
|
|
@@ -38,13 +38,21 @@ function magicLinkUrl(ctx) {
|
|
|
38
38
|
return url;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The page running the passkey ceremony: the browser's Origin header, else this request's own
|
|
43
|
+
* origin (same-origin setups). Voult picks the passkey domain (RP ID) from it, among the app's
|
|
44
|
+
* allowlisted origins.
|
|
45
|
+
* @param {Context} ctx
|
|
46
|
+
*/
|
|
47
|
+
const pageOrigin = (ctx) => ctx.request.headers.get('origin') ?? `${ctx.url.protocol}//${ctx.url.host}`;
|
|
48
|
+
|
|
41
49
|
/** @type {Route[]} */
|
|
42
50
|
export const passwordlessRoutes = [
|
|
43
51
|
['POST', '/passkeys/register/options', authenticated(async (ctx) => (
|
|
44
|
-
json(await createPasskeyRegistrationOptions({ deviceName: ctx.body.deviceName }, ctx.client))
|
|
52
|
+
json(await createPasskeyRegistrationOptions({ deviceName: ctx.body.deviceName, origin: pageOrigin(ctx) }, ctx.client))
|
|
45
53
|
))],
|
|
46
54
|
['POST', '/passkeys/register/verify', authenticated(async (ctx) => (
|
|
47
|
-
json(await verifyPasskeyRegistration(ctx.body.credential, { deviceName: ctx.body.deviceName }, ctx.client))
|
|
55
|
+
json(await verifyPasskeyRegistration(ctx.body.credential, { deviceName: ctx.body.deviceName, origin: pageOrigin(ctx) }, ctx.client))
|
|
48
56
|
))],
|
|
49
57
|
['GET', '/passkeys', authenticated(async (ctx) => json(await listPasskeys(ctx.client)))],
|
|
50
58
|
['PATCH', '/passkeys/:id', authenticated(async (ctx) => (
|
|
@@ -54,9 +62,9 @@ export const passwordlessRoutes = [
|
|
|
54
62
|
|
|
55
63
|
// Public: sign in with a passkey. Sets the session (or the MFA-pending cookie) like password sign-in.
|
|
56
64
|
['POST', '/passkeys/login/options', async (ctx) => (
|
|
57
|
-
json(await createPasskeyLoginOptions(ctx.body.email
|
|
65
|
+
json(await createPasskeyLoginOptions({ ...(ctx.body.email && { email: ctx.body.email }), origin: pageOrigin(ctx) }, ctx.client))
|
|
58
66
|
)],
|
|
59
|
-
['POST', '/passkeys/login/verify', async (ctx) => authResult(ctx, await verifyPasskeyLogin(ctx.body.credential, ctx.client))],
|
|
67
|
+
['POST', '/passkeys/login/verify', async (ctx) => authResult(ctx, await verifyPasskeyLogin(ctx.body.credential, { origin: pageOrigin(ctx) }, ctx.client))],
|
|
60
68
|
|
|
61
69
|
// Public. Answers the same whether or not the email has an account, so it can't be used to
|
|
62
70
|
// find out who signed up. Mistakes in the request or the app's setup still come back as errors.
|
|
@@ -80,7 +88,7 @@ export const passwordlessRoutes = [
|
|
|
80
88
|
try {
|
|
81
89
|
result = await verifyEmailLink(ctx.query('token'), ctx.client);
|
|
82
90
|
} catch (err) {
|
|
83
|
-
return
|
|
91
|
+
return redirectWithFailure(config, err, 'magic link');
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
if (result.mfaRequired) {
|
package/src/routes.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getMfaStatus,
|
|
10
10
|
regenerateMfaBackupCodes,
|
|
11
11
|
revokeSession,
|
|
12
|
+
setPassword,
|
|
12
13
|
setupMfa,
|
|
13
14
|
unlinkOAuthProvider,
|
|
14
15
|
refreshSession,
|
|
@@ -195,6 +196,16 @@ export const authRoutes = [
|
|
|
195
196
|
json(await unlinkOAuthProvider(ctx.params.provider, ctx.client))
|
|
196
197
|
))],
|
|
197
198
|
|
|
199
|
+
// Signed up with Google/GitHub only: add a password, so email + password sign-in works too.
|
|
200
|
+
['POST', '/user/password', authenticated(async (ctx) => json(await setPassword(ctx.body.password, ctx.client)))],
|
|
201
|
+
|
|
202
|
+
// The user's own activity (sign-ins, MFA and password changes, …), newest first. ?limit=&skip=
|
|
203
|
+
// (No SDK function for it yet, so the client calls the API path directly.)
|
|
204
|
+
['GET', '/user/activity', authenticated(async (ctx) => json(await ctx.client.get('/api/audit-logs/me', {
|
|
205
|
+
requireAuth: true,
|
|
206
|
+
params: { limit: ctx.query('limit') ?? '20', skip: ctx.query('skip') ?? '0' },
|
|
207
|
+
})))],
|
|
208
|
+
|
|
198
209
|
// Voult disables the account (it can be re-enabled), and this browser is signed out.
|
|
199
210
|
['DELETE', '/user/me', authenticated(async (ctx) => {
|
|
200
211
|
const result = await deleteUser(ctx.client);
|