@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 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.2.1",
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.0"
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
- * Normalize SDK and unexpected errors into a stable JSON shape.
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 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
- };
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 status = typeof err?.status === 'number' ? err.status : 500;
23
+ const apiCode = isVoult && err.apiCode && err.apiCode !== 'UNKNOWN_ERROR' ? err.apiCode : undefined;
22
24
  return {
23
- error: {
24
- code: err?.code || 'INTERNAL_ERROR',
25
- message: err?.message || 'Internal server error',
26
- status,
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 function normalizeVoultError(err: unknown): { error: { code: string; message: string; status: number; field?: string; fields?: unknown } };
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
- export function errorCode(err) {
80
- return err?.apiCode || err?.code || 'OAUTH_FAILED';
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 redirectWithError(config, errorCode(err), err.message);
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 redirectWithError(config, errorCode(err), err.message);
203
+ return redirectWithFailure(config, err, 'OAuth');
201
204
  }
202
205
 
203
206
  if (result.mfaRequired) {
@@ -9,7 +9,7 @@ import {
9
9
  verifyPasskeyLogin,
10
10
  verifyPasskeyRegistration,
11
11
  } from '@voult/sdk';
12
- import { appLocation, errorCode, oauthCallbackUrl, redirectWithError, safeReturnTo } from './oauth.js';
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 ? { email: ctx.body.email } : {}, ctx.client))
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 redirectWithError(config, errorCode(err), err.message);
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);