@voult/core 0.2.0 → 0.3.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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `@voult/core` are documented here.
4
4
 
5
+ ## 0.3.0
6
+
7
+ - **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.
8
+ - **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`.
9
+ - 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.
10
+
11
+ ## 0.2.1
12
+
13
+ - 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.
14
+ - New entry point `@voult/core/cookies` (signing and cookie parsing only, no `@voult/sdk`), for Edge middleware.
15
+ - `parseUserCookie` and `safeReturnTo` are exported.
16
+
5
17
  ## 0.2.0
6
18
 
7
19
  The router now serves every end-user feature, so apps write no auth route handlers. New routes (below the mount path):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voult/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -10,6 +10,11 @@
10
10
  "types": "./src/index.d.ts",
11
11
  "import": "./src/index.js",
12
12
  "default": "./src/index.js"
13
+ },
14
+ "./cookies": {
15
+ "types": "./src/index.d.ts",
16
+ "import": "./src/cookies.js",
17
+ "default": "./src/cookies.js"
13
18
  }
14
19
  },
15
20
  "sideEffects": false,
@@ -32,7 +37,7 @@
32
37
  "author": "Samuel Olabode",
33
38
  "license": "MIT",
34
39
  "peerDependencies": {
35
- "@voult/sdk": ">=0.2.0"
40
+ "@voult/sdk": ">=0.2.2"
36
41
  },
37
42
  "devDependencies": {
38
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';
@@ -88,6 +93,9 @@ export function restoreSession(
88
93
  config: VoultConfig
89
94
  ): IncomingSession;
90
95
  export function sessionCookies(client: VoultClient, incoming: IncomingSession, config: VoultConfig): CookieToSet[];
96
+ /** `value` when it's a same-site relative path (`/x`, not `//x` or `https://…`), else `fallback`. */
97
+ export function safeReturnTo(value: unknown, fallback: string): string;
98
+ export function parseUserCookie(value: unknown): Record<string, unknown> | null;
91
99
  export function renewSession(client: VoultClient, read: ReadCookie, config: VoultConfig): Promise<CookieToSet[]>;
92
100
 
93
101
  export function readCookieHeader(header: string | null | undefined, secret?: string): Promise<{
package/src/index.js CHANGED
@@ -2,7 +2,8 @@
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
+ export { safeReturnTo } from './oauth.js';
6
7
  export {
7
8
  COOKIE_ACCESS,
8
9
  COOKIE_MFA_PENDING,
@@ -11,6 +12,7 @@ export {
11
12
  COOKIE_USER,
12
13
  createVoultClient,
13
14
  getSessionFromRequest,
15
+ parseUserCookie,
14
16
  renewSession,
15
17
  restoreSession,
16
18
  sessionCookies,
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() {
@@ -100,6 +103,19 @@ export function oauthRoutes() {
100
103
  let providersCache = { at: 0, value: null };
101
104
 
102
105
  return [
106
+ // For pages rendered where cookies can't be written (Next.js Server Components): renewing
107
+ // there would rotate the refresh token without saving it. They send the browser here
108
+ // instead; this renews, saves the new cookies and goes back.
109
+ ['GET', '/session/renew', async (ctx) => {
110
+ // The caller already knows the access token is stale (its cookie outlives the token).
111
+ ctx.client.accessToken = null;
112
+ await ctx.renew();
113
+ const back = safeReturnTo(ctx.query('returnTo'), ctx.config.oauth.successPath);
114
+ return ctx.client.accessToken
115
+ ? redirect(appLocation(ctx.config, back))
116
+ : redirectWithError(ctx.config, 'SESSION_EXPIRED', 'Please sign in again.');
117
+ }],
118
+
103
119
  ['GET', '/oauth/providers', async (ctx) => {
104
120
  if (!providersCache.value || Date.now() - providersCache.at > PROVIDERS_CACHE_MS) {
105
121
  const info = await getAppInfo(ctx.client);
@@ -142,7 +158,7 @@ export function oauthRoutes() {
142
158
  ? await linkOAuthProvider(provider, { redirectUri, state: nonce }, client)
143
159
  : await getOAuthAuthorizationUrl(provider, { intent, redirectUri, state: nonce }, client));
144
160
  } catch (err) {
145
- return redirectWithError(config, errorCode(err), err.message);
161
+ return redirectWithFailure(config, err, 'OAuth');
146
162
  }
147
163
 
148
164
  // Ties the callback to this browser: Voult echoes `state`, we compare it to this cookie.
@@ -184,7 +200,7 @@ export function oauthRoutes() {
184
200
  try {
185
201
  result = await exchangeOAuthCode(voultCode, { redirectUri: saved.redirectUri }, client);
186
202
  } catch (err) {
187
- return redirectWithError(config, errorCode(err), err.message);
203
+ return redirectWithFailure(config, err, 'OAuth');
188
204
  }
189
205
 
190
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) {