@voult/core 0.1.0 → 0.2.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,21 @@
2
2
 
3
3
  All notable changes to `@voult/core` are documented here.
4
4
 
5
+ ## 0.2.1
6
+
7
+ - 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.
8
+ - New entry point `@voult/core/cookies` (signing and cookie parsing only, no `@voult/sdk`), for Edge middleware.
9
+ - `parseUserCookie` and `safeReturnTo` are exported.
10
+
11
+ ## 0.2.0
12
+
13
+ The router now serves every end-user feature, so apps write no auth route handlers. New routes (below the mount path):
14
+
15
+ - **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`.
16
+ - **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=…`).
17
+ - Magic links point at `/magic-link/verify` next to the OAuth callback, so the one callback URL on the app's allowlist covers both.
18
+ - 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.
19
+
5
20
  ## 0.1.0
6
21
 
7
22
  First release. The Voult router as one function on the Fetch API, so adapters only translate requests:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "0.2.1",
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",
7
7
  "types": "./src/index.d.ts",
@@ -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,
package/src/handler.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { setCookieHeader } from './cookies.js';
2
2
  import { normalizeVoultError } from './errors.js';
3
3
  import { oauthRoutes } from './oauth.js';
4
+ import { passwordlessRoutes } from './passwordless.js';
4
5
  import { authRoutes, json } from './routes.js';
5
6
  import { getSessionFromRequest, renewSession, sessionCookies } from './session.js';
6
7
 
@@ -104,7 +105,7 @@ const defaultNotFound = () => Response.json(
104
105
  * @param {VoultConfig} config A normalized config (`resolveConfig` / `loadConfigFromEnv`).
105
106
  */
106
107
  export function createVoultHandler(config) {
107
- const routes = [...authRoutes, ...oauthRoutes()].map(compile);
108
+ const routes = [...authRoutes, ...passwordlessRoutes, ...oauthRoutes()].map(compile);
108
109
 
109
110
  /**
110
111
  * @param {Request} request
package/src/index.d.ts CHANGED
@@ -88,6 +88,9 @@ export function restoreSession(
88
88
  config: VoultConfig
89
89
  ): IncomingSession;
90
90
  export function sessionCookies(client: VoultClient, incoming: IncomingSession, config: VoultConfig): CookieToSet[];
91
+ /** `value` when it's a same-site relative path (`/x`, not `//x` or `https://…`), else `fallback`. */
92
+ export function safeReturnTo(value: unknown, fallback: string): string;
93
+ export function parseUserCookie(value: unknown): Record<string, unknown> | null;
91
94
  export function renewSession(client: VoultClient, read: ReadCookie, config: VoultConfig): Promise<CookieToSet[]>;
92
95
 
93
96
  export function readCookieHeader(header: string | null | undefined, secret?: string): Promise<{
package/src/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  export { createVoultHandler } from './handler.js';
4
4
  export { loadConfigFromEnv, resolveConfig } from './config.js';
5
5
  export { normalizeVoultError } 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
@@ -50,7 +50,7 @@ export function safeReturnTo(value, fallback) {
50
50
  * @param {string} path
51
51
  * @param {Record<string, string | undefined>} [params]
52
52
  */
53
- function appLocation(config, path, params = {}) {
53
+ export function appLocation(config, path, params = {}) {
54
54
  const query = new URLSearchParams(
55
55
  Object.entries(params).filter(([, value]) => value != null && value !== '')
56
56
  ).toString();
@@ -69,14 +69,14 @@ export function oauthCallbackUrl(ctx) {
69
69
  return ctx.config.oauthCallbackUrl || `${ctx.url.protocol}//${ctx.url.host}${ctx.basePath}/oauth/callback`;
70
70
  }
71
71
 
72
- function redirectWithError(config, code, description) {
72
+ export function redirectWithError(config, code, description) {
73
73
  return redirect(appLocation(config, config.oauth.errorPath, {
74
74
  voult_error: code,
75
75
  voult_error_description: description,
76
76
  }));
77
77
  }
78
78
 
79
- function errorCode(err) {
79
+ export function errorCode(err) {
80
80
  return err?.apiCode || err?.code || 'OAUTH_FAILED';
81
81
  }
82
82
 
@@ -100,6 +100,19 @@ export function oauthRoutes() {
100
100
  let providersCache = { at: 0, value: null };
101
101
 
102
102
  return [
103
+ // For pages rendered where cookies can't be written (Next.js Server Components): renewing
104
+ // there would rotate the refresh token without saving it. They send the browser here
105
+ // instead; this renews, saves the new cookies and goes back.
106
+ ['GET', '/session/renew', async (ctx) => {
107
+ // The caller already knows the access token is stale (its cookie outlives the token).
108
+ ctx.client.accessToken = null;
109
+ await ctx.renew();
110
+ const back = safeReturnTo(ctx.query('returnTo'), ctx.config.oauth.successPath);
111
+ return ctx.client.accessToken
112
+ ? redirect(appLocation(ctx.config, back))
113
+ : redirectWithError(ctx.config, 'SESSION_EXPIRED', 'Please sign in again.');
114
+ }],
115
+
103
116
  ['GET', '/oauth/providers', async (ctx) => {
104
117
  if (!providersCache.value || Date.now() - providersCache.at > PROVIDERS_CACHE_MS) {
105
118
  const info = await getAppInfo(ctx.client);
@@ -0,0 +1,94 @@
1
+ import {
2
+ createPasskeyLoginOptions,
3
+ createPasskeyRegistrationOptions,
4
+ deletePasskey,
5
+ listPasskeys,
6
+ signInWithEmailLink,
7
+ updatePasskey,
8
+ verifyEmailLink,
9
+ verifyPasskeyLogin,
10
+ verifyPasskeyRegistration,
11
+ } from '@voult/sdk';
12
+ import { appLocation, errorCode, oauthCallbackUrl, redirectWithError, safeReturnTo } from './oauth.js';
13
+ import { authResult, authenticated, json, redirect } from './routes.js';
14
+ import { COOKIE_MFA_PENDING, MFA_PENDING_MAX_AGE_MS, setCookie } from './session.js';
15
+
16
+ // Passkeys and magic links: sign-ins without a password.
17
+ // Passkeys: the page runs the WebAuthn ceremony (@simplewebauthn/browser or navigator.credentials)
18
+ // with the options from here, then posts the browser's credential back.
19
+ // Magic links: Voult emails a link to GET /magic-link/verify on this server, which sets the
20
+ // session and redirects like the OAuth callback.
21
+
22
+ /**
23
+ * @typedef {import('./handler.js').Context} Context
24
+ * @typedef {import('./handler.js').Route} Route
25
+ */
26
+
27
+ /**
28
+ * The URL magic links open: next to the OAuth callback, so the one callback URL on the app's
29
+ * allowlist covers it too (Voult accepts any page on an allowlisted callback's origin).
30
+ * @param {Context} ctx
31
+ */
32
+ function magicLinkUrl(ctx) {
33
+ const url = new URL(oauthCallbackUrl(ctx));
34
+ url.pathname = url.pathname.endsWith('/oauth/callback')
35
+ ? url.pathname.replace(/\/oauth\/callback$/, '/magic-link/verify')
36
+ : `${ctx.basePath}/magic-link/verify`;
37
+ url.search = '';
38
+ return url;
39
+ }
40
+
41
+ /** @type {Route[]} */
42
+ export const passwordlessRoutes = [
43
+ ['POST', '/passkeys/register/options', authenticated(async (ctx) => (
44
+ json(await createPasskeyRegistrationOptions({ deviceName: ctx.body.deviceName }, ctx.client))
45
+ ))],
46
+ ['POST', '/passkeys/register/verify', authenticated(async (ctx) => (
47
+ json(await verifyPasskeyRegistration(ctx.body.credential, { deviceName: ctx.body.deviceName }, ctx.client))
48
+ ))],
49
+ ['GET', '/passkeys', authenticated(async (ctx) => json(await listPasskeys(ctx.client)))],
50
+ ['PATCH', '/passkeys/:id', authenticated(async (ctx) => (
51
+ json(await updatePasskey(ctx.params.id, ctx.body.deviceName, ctx.client))
52
+ ))],
53
+ ['DELETE', '/passkeys/:id', authenticated(async (ctx) => json(await deletePasskey(ctx.params.id, ctx.client)))],
54
+
55
+ // Public: sign in with a passkey. Sets the session (or the MFA-pending cookie) like password sign-in.
56
+ ['POST', '/passkeys/login/options', async (ctx) => (
57
+ json(await createPasskeyLoginOptions(ctx.body.email ? { email: ctx.body.email } : {}, ctx.client))
58
+ )],
59
+ ['POST', '/passkeys/login/verify', async (ctx) => authResult(ctx, await verifyPasskeyLogin(ctx.body.credential, ctx.client))],
60
+
61
+ // Public. Answers the same whether or not the email has an account, so it can't be used to
62
+ // find out who signed up. Mistakes in the request or the app's setup still come back as errors.
63
+ ['POST', '/magic-link', async (ctx) => {
64
+ const url = magicLinkUrl(ctx);
65
+ const returnTo = safeReturnTo(ctx.body.returnTo, '');
66
+ if (returnTo) url.searchParams.set('returnTo', returnTo);
67
+ try {
68
+ await signInWithEmailLink(ctx.body.email, { redirectUri: url.href }, ctx.client);
69
+ } catch (err) {
70
+ if (err?.status !== 404) throw err;
71
+ }
72
+ return json({ sent: true });
73
+ }],
74
+
75
+ // ponytail: a GET that spends a one-time token: mail scanners that open links can use it up first.
76
+ // Upgrade path: a confirm page that POSTs, if users report "link expired" on first click.
77
+ ['GET', '/magic-link/verify', async (ctx) => {
78
+ const { config } = ctx;
79
+ let result;
80
+ try {
81
+ result = await verifyEmailLink(ctx.query('token'), ctx.client);
82
+ } catch (err) {
83
+ return redirectWithError(config, errorCode(err), err.message);
84
+ }
85
+
86
+ if (result.mfaRequired) {
87
+ ctx.cookies.push(setCookie(config, COOKIE_MFA_PENDING, result.mfaPendingToken, { maxAge: MFA_PENDING_MAX_AGE_MS }));
88
+ return redirect(appLocation(config, config.oauth.mfaPath));
89
+ }
90
+
91
+ // The session cookies go out with this redirect (sessionCookies, in the handler).
92
+ return redirect(appLocation(config, safeReturnTo(ctx.query('returnTo'), config.oauth.successPath)));
93
+ }],
94
+ ];
package/src/routes.js CHANGED
@@ -1,7 +1,16 @@
1
1
  import {
2
2
  AuthenticationError,
3
+ ENDPOINTS,
4
+ deleteUser,
5
+ disableMfa,
6
+ enableMfa,
3
7
  getCurrentUser,
8
+ getLinkedOAuthProviders,
4
9
  getMfaStatus,
10
+ regenerateMfaBackupCodes,
11
+ revokeSession,
12
+ setupMfa,
13
+ unlinkOAuthProvider,
5
14
  refreshSession,
6
15
  resetPassword,
7
16
  sendPasswordResetEmail,
@@ -15,7 +24,14 @@ import {
15
24
  verifyMfaLogin,
16
25
  } from '@voult/sdk';
17
26
  import { normalizeVoultError } from './errors.js';
18
- import { COOKIE_MFA_PENDING, MFA_PENDING_MAX_AGE_MS, clearCookie, setCookie, toPublicAuthResult } from './session.js';
27
+ import {
28
+ COOKIE_MFA_PENDING,
29
+ MFA_PENDING_MAX_AGE_MS,
30
+ clearCookie,
31
+ clearSessionCookies,
32
+ setCookie,
33
+ toPublicAuthResult,
34
+ } from './session.js';
19
35
 
20
36
  /**
21
37
  * @typedef {import('./handler.js').Context} Context
@@ -44,7 +60,7 @@ export function authenticated(handler) {
44
60
  }
45
61
 
46
62
  /** @param {Context} ctx @param {object} result */
47
- function authResult(ctx, result) {
63
+ export function authResult(ctx, result) {
48
64
  // Park the MFA pending token server-side too (as hosted OAuth does), so /mfa/verify needs only
49
65
  // the code and a page reload doesn't lose the sign-in. The JSON still carries it for older clients.
50
66
  if (result?.mfaRequired && result.mfaPendingToken && ctx.config.session.strategy === 'cookie') {
@@ -53,6 +69,23 @@ function authResult(ctx, result) {
53
69
  return json(toPublicAuthResult(result, ctx.config));
54
70
  }
55
71
 
72
+ /** The session is over on this device (account disabled, own session revoked): drop it and its cookies. */
73
+ function endLocalSession(ctx) {
74
+ ctx.client.clearSession();
75
+ ctx.cookies.push(...clearSessionCookies(ctx.config));
76
+ }
77
+
78
+ /**
79
+ * The user's sessions, with `isCurrent` on this browser's. The API marks it when it sees the
80
+ * refresh token, which only this server holds (never the page).
81
+ * @param {Context} ctx
82
+ */
83
+ async function listSessions(ctx) {
84
+ const headers = ctx.client.refreshToken ? { 'X-Refresh-Token': ctx.client.refreshToken } : {};
85
+ const response = await ctx.client.get(ENDPOINTS.SESSIONS, { requireAuth: true, headers });
86
+ return { sessions: response.sessions ?? [] };
87
+ }
88
+
56
89
  /** @type {Route[]} */
57
90
  export const authRoutes = [
58
91
  ['GET', '/session', async (ctx) => {
@@ -131,4 +164,41 @@ export const authRoutes = [
131
164
  }],
132
165
 
133
166
  ['GET', '/mfa/status', authenticated(async (ctx) => json(await getMfaStatus(ctx.client)))],
167
+
168
+ // MFA enrolment: setup returns the secret, QR code and backup codes; enable confirms with a code.
169
+ ['POST', '/mfa/setup', authenticated(async (ctx) => json(await setupMfa(ctx.client)))],
170
+ ['POST', '/mfa/enable', authenticated(async (ctx) => json(await enableMfa(ctx.body.code, ctx.client)))],
171
+ // `password` is optional so accounts created with Google/GitHub (no password) can turn MFA off.
172
+ ['POST', '/mfa/disable', authenticated(async (ctx) => (
173
+ json(await disableMfa(ctx.body.password, ctx.body.code, ctx.client))
174
+ ))],
175
+ ['POST', '/mfa/backup-codes', authenticated(async (ctx) => (
176
+ json(await regenerateMfaBackupCodes(ctx.body.code, ctx.client))
177
+ ))],
178
+ // Abandon a sign-in waiting for its MFA code (there's no session yet, so /logout can't).
179
+ ['POST', '/mfa/cancel', async (ctx) => {
180
+ ctx.cookies.push(clearCookie(ctx.config, COOKIE_MFA_PENDING));
181
+ return json({ success: true });
182
+ }],
183
+
184
+ ['GET', '/sessions', authenticated(async (ctx) => json(await listSessions(ctx)))],
185
+ ['DELETE', '/sessions/:id', authenticated(async (ctx) => {
186
+ const { sessions } = await listSessions(ctx);
187
+ const current = sessions.some((session) => String(session.id) === ctx.params.id && session.isCurrent);
188
+ const result = await revokeSession(ctx.params.id, ctx.client);
189
+ if (current) endLocalSession(ctx);
190
+ return json({ ...result, current });
191
+ })],
192
+
193
+ ['GET', '/oauth/linked', authenticated(async (ctx) => json(await getLinkedOAuthProviders(ctx.client)))],
194
+ ['DELETE', '/oauth/linked/:provider', authenticated(async (ctx) => (
195
+ json(await unlinkOAuthProvider(ctx.params.provider, ctx.client))
196
+ ))],
197
+
198
+ // Voult disables the account (it can be re-enabled), and this browser is signed out.
199
+ ['DELETE', '/user/me', authenticated(async (ctx) => {
200
+ const result = await deleteUser(ctx.client);
201
+ endLocalSession(ctx);
202
+ return json(result);
203
+ })],
134
204
  ];