@voult/core 0.1.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 +9 -0
- package/package.json +2 -2
- package/src/handler.js +2 -1
- package/src/oauth.js +3 -3
- package/src/passwordless.js +94 -0
- package/src/routes.js +72 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@voult/core` are documented here.
|
|
4
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
|
+
|
|
5
14
|
## 0.1.0
|
|
6
15
|
|
|
7
16
|
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.
|
|
4
|
-
"description": "The Voult auth router on the Fetch API (Request
|
|
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
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./src/index.d.ts",
|
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/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
|
|
|
@@ -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 {
|
|
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
|
];
|