@voult/core 0.0.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/src/index.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ /** The per-request `@voult/sdk` VoultClient (the parts adapters use). */
2
+ export interface VoultClient {
3
+ accessToken: string | null;
4
+ refreshToken: string | null;
5
+ user: unknown;
6
+ isAuthenticated(): boolean;
7
+ getCurrentUser(): unknown;
8
+ setSession(user: unknown, accessToken: string, refreshToken?: string | null): void;
9
+ clearSession(): void;
10
+ }
11
+
12
+ export type SessionStrategy = 'cookie' | 'bearer';
13
+
14
+ /** Where hosted OAuth sends the browser, relative to `appUrl`. Each must start with "/". */
15
+ export interface VoultOAuthPaths {
16
+ /** Default `/`. After sign-in (unless `returnTo` was given) or linking. */
17
+ successPath?: string;
18
+ /** Default `/mfa`. User has MFA: prompt for a code and POST /mfa/verify { mfaToken }. */
19
+ mfaPath?: string;
20
+ /** Default `/login`. Receives `?voult_error=<CODE>&voult_error_description=<text>`. */
21
+ errorPath?: string;
22
+ }
23
+
24
+ export interface VoultConfig {
25
+ baseURL?: string;
26
+ clientId: string;
27
+ clientSecret: string;
28
+ sessionSecret?: string;
29
+ appUrl?: string;
30
+ /** Override the computed hosted-OAuth callback URL (e.g. behind a proxy). */
31
+ oauthCallbackUrl?: string;
32
+ oauth?: VoultOAuthPaths;
33
+ session?: { strategy?: SessionStrategy };
34
+ }
35
+
36
+ export interface LoadConfigFromEnvOptions {
37
+ env?: Record<string, string | undefined>;
38
+ overrides?: Partial<VoultConfig>;
39
+ }
40
+
41
+ export interface ResolveConfigOptions extends LoadConfigFromEnvOptions {
42
+ config?: VoultConfig;
43
+ oauth?: VoultOAuthPaths;
44
+ }
45
+
46
+ export interface CookieToSet {
47
+ name: string;
48
+ value: string;
49
+ options: Record<string, unknown>;
50
+ }
51
+
52
+ export type ReadCookie = (name: string) => unknown;
53
+
54
+ export interface IncomingSession {
55
+ accessToken: string | null;
56
+ refreshToken: string | null;
57
+ }
58
+
59
+ export interface HandleOptions {
60
+ /** Where the router is mounted, e.g. `/api/auth`. */
61
+ basePath?: string;
62
+ /** Answer for paths with no route. Default: 404 JSON. Return null to fall through. */
63
+ notFound?: (request: Request) => Response | null;
64
+ }
65
+
66
+ export type VoultHandler = (request: Request, options?: HandleOptions) => Promise<Response | null>;
67
+
68
+ export function createVoultHandler(config: VoultConfig): VoultHandler;
69
+ export function loadConfigFromEnv(options?: LoadConfigFromEnvOptions): VoultConfig;
70
+ export function resolveConfig(options?: ResolveConfigOptions): VoultConfig;
71
+ export function normalizeVoultError(err: unknown): { error: { code: string; message: string; status: number; field?: string; fields?: unknown } };
72
+
73
+ export const COOKIE_ACCESS: 'voult_access';
74
+ export const COOKIE_REFRESH: 'voult_refresh';
75
+ export const COOKIE_USER: 'voult_user';
76
+ export const COOKIE_OAUTH: 'voult_oauth';
77
+ export const COOKIE_MFA_PENDING: 'voult_mfa_pending';
78
+
79
+ export function createVoultClient(config: VoultConfig): VoultClient;
80
+ export function getSessionFromRequest(
81
+ request: Request,
82
+ config: VoultConfig,
83
+ options?: { body?: Record<string, unknown> }
84
+ ): Promise<{ client: VoultClient; incoming: IncomingSession; read: ReadCookie }>;
85
+ export function restoreSession(
86
+ client: VoultClient,
87
+ incoming: { read: ReadCookie; authorization?: string | null; refreshToken?: unknown },
88
+ config: VoultConfig
89
+ ): IncomingSession;
90
+ export function sessionCookies(client: VoultClient, incoming: IncomingSession, config: VoultConfig): CookieToSet[];
91
+ export function renewSession(client: VoultClient, read: ReadCookie, config: VoultConfig): Promise<CookieToSet[]>;
92
+
93
+ export function readCookieHeader(header: string | null | undefined, secret?: string): Promise<{
94
+ cookies: Record<string, string>;
95
+ signedCookies: Record<string, string | false>;
96
+ }>;
97
+ export function serializeCookie(name: string, value: string, options?: Record<string, unknown>): string;
98
+ export function setCookieHeader(cookie: CookieToSet, secret?: string): Promise<string>;
99
+ export function sign(value: string, secret: string): Promise<string>;
100
+ export function unsign(signed: string, secret: string): Promise<string | false>;
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // @voult/core: the Voult router on the Fetch API, for adapters (@voult/express, @voult/next).
2
+ // Apps install an adapter, not this package.
3
+ export { createVoultHandler } from './handler.js';
4
+ export { loadConfigFromEnv, resolveConfig } from './config.js';
5
+ export { normalizeVoultError } from './errors.js';
6
+ export {
7
+ COOKIE_ACCESS,
8
+ COOKIE_MFA_PENDING,
9
+ COOKIE_OAUTH,
10
+ COOKIE_REFRESH,
11
+ COOKIE_USER,
12
+ createVoultClient,
13
+ getSessionFromRequest,
14
+ renewSession,
15
+ restoreSession,
16
+ sessionCookies,
17
+ } from './session.js';
18
+ export { readCookieHeader, serializeCookie, setCookieHeader, sign, unsign } from './cookies.js';
package/src/oauth.js ADDED
@@ -0,0 +1,199 @@
1
+ import {
2
+ exchangeOAuthCode,
3
+ getAppInfo,
4
+ getOAuthAuthorizationUrl,
5
+ linkOAuthProvider,
6
+ } from '@voult/sdk';
7
+ import { base64 } from './cookies.js';
8
+ import { json, redirect } from './routes.js';
9
+ import {
10
+ COOKIE_MFA_PENDING,
11
+ COOKIE_OAUTH,
12
+ MFA_PENDING_MAX_AGE_MS,
13
+ OAUTH_COOKIE_MAX_AGE_MS,
14
+ clearCookie,
15
+ setCookie,
16
+ } from './session.js';
17
+
18
+ // Hosted OAuth: provider credentials live in the Voult dashboard, never in this server's env.
19
+ // GET /oauth/providers → which providers the app can use
20
+ // GET /oauth/:provider/start → 302 to the provider (via Voult)
21
+ // GET /oauth/callback → Voult sends the browser back here; we set the session
22
+
23
+ /**
24
+ * @typedef {import('./handler.js').Context} Context
25
+ * @typedef {import('./handler.js').Route} Route
26
+ * @typedef {import('./index.js').VoultConfig} VoultConfig
27
+ */
28
+
29
+ const PROVIDERS_CACHE_MS = 60 * 1000;
30
+ const INTENTS = ['authenticate', 'login', 'register', 'link'];
31
+
32
+ /**
33
+ * Only same-site relative paths, so `returnTo` can't send users to another site.
34
+ * @param {unknown} value
35
+ * @param {string} fallback
36
+ */
37
+ export function safeReturnTo(value, fallback) {
38
+ return typeof value === 'string'
39
+ && value.length <= 2048
40
+ && value.startsWith('/')
41
+ && !value.startsWith('//')
42
+ && !value.includes('\\')
43
+ ? value
44
+ : fallback;
45
+ }
46
+
47
+ /**
48
+ * A browser URL on the integrator's frontend (VOULT_APP_URL), or same-origin when unset.
49
+ * @param {VoultConfig} config
50
+ * @param {string} path
51
+ * @param {Record<string, string | undefined>} [params]
52
+ */
53
+ export function appLocation(config, path, params = {}) {
54
+ const query = new URLSearchParams(
55
+ Object.entries(params).filter(([, value]) => value != null && value !== '')
56
+ ).toString();
57
+ const base = config.appUrl ? config.appUrl.replace(/\/$/, '') : '';
58
+ const joiner = path.includes('?') ? '&' : '?';
59
+ return `${base}${path}${query ? `${joiner}${query}` : ''}`;
60
+ }
61
+
62
+ /**
63
+ * The URL Voult sends the browser back to. Must be on the app's callback allowlist.
64
+ * Behind a proxy, let the adapter see the public URL (Express: `app.set('trust proxy', 1)`)
65
+ * or set VOULT_OAUTH_CALLBACK_URL.
66
+ * @param {Context} ctx
67
+ */
68
+ export function oauthCallbackUrl(ctx) {
69
+ return ctx.config.oauthCallbackUrl || `${ctx.url.protocol}//${ctx.url.host}${ctx.basePath}/oauth/callback`;
70
+ }
71
+
72
+ export function redirectWithError(config, code, description) {
73
+ return redirect(appLocation(config, config.oauth.errorPath, {
74
+ voult_error: code,
75
+ voult_error_description: description,
76
+ }));
77
+ }
78
+
79
+ export function errorCode(err) {
80
+ return err?.apiCode || err?.code || 'OAUTH_FAILED';
81
+ }
82
+
83
+ function newNonce() {
84
+ return base64(globalThis.crypto.getRandomValues(new Uint8Array(24)))
85
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
86
+ }
87
+
88
+ function readOAuthCookie(ctx) {
89
+ const raw = ctx.read(COOKIE_OAUTH);
90
+ if (typeof raw !== 'string') return null;
91
+ try {
92
+ return JSON.parse(raw);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** One provider cache per handler. @returns {Route[]} */
99
+ export function oauthRoutes() {
100
+ let providersCache = { at: 0, value: null };
101
+
102
+ return [
103
+ ['GET', '/oauth/providers', async (ctx) => {
104
+ if (!providersCache.value || Date.now() - providersCache.at > PROVIDERS_CACHE_MS) {
105
+ const info = await getAppInfo(ctx.client);
106
+ const providers = Object.fromEntries(
107
+ Object.entries(info.providers || {}).map(([name, s]) => [name, Boolean(s.enabled && s.configured)])
108
+ );
109
+ providersCache = { at: Date.now(), value: { providers } };
110
+ }
111
+ return json(providersCache.value);
112
+ }],
113
+
114
+ ['GET', '/oauth/:provider/start', async (ctx) => {
115
+ const { config, client } = ctx;
116
+ if (config.session.strategy !== 'cookie') {
117
+ return json({
118
+ error: {
119
+ code: 'OAUTH_REQUIRES_COOKIE_SESSION',
120
+ message: 'Hosted OAuth sets the session in a cookie. Use VOULT_SESSION_STRATEGY=cookie, or call the @voult/sdk OAuth functions yourself.',
121
+ status: 400,
122
+ },
123
+ }, 400);
124
+ }
125
+
126
+ const provider = String(ctx.params.provider).toLowerCase();
127
+ const intent = ctx.query('intent') ?? 'authenticate';
128
+ if (!INTENTS.includes(intent)) {
129
+ return redirectWithError(config, 'INVALID_INTENT', `intent must be one of: ${INTENTS.join(', ')}`);
130
+ }
131
+ if (intent === 'link' && !client.accessToken) {
132
+ return redirectWithError(config, 'LOGIN_REQUIRED', 'Sign in before linking another account.');
133
+ }
134
+
135
+ const nonce = newNonce();
136
+ const redirectUri = oauthCallbackUrl(ctx);
137
+ const returnTo = safeReturnTo(ctx.query('returnTo'), config.oauth.successPath);
138
+
139
+ let authUrl;
140
+ try {
141
+ ({ authUrl } = intent === 'link'
142
+ ? await linkOAuthProvider(provider, { redirectUri, state: nonce }, client)
143
+ : await getOAuthAuthorizationUrl(provider, { intent, redirectUri, state: nonce }, client));
144
+ } catch (err) {
145
+ return redirectWithError(config, errorCode(err), err.message);
146
+ }
147
+
148
+ // Ties the callback to this browser: Voult echoes `state`, we compare it to this cookie.
149
+ ctx.cookies.push(setCookie(
150
+ config,
151
+ COOKIE_OAUTH,
152
+ JSON.stringify({ nonce, provider, intent, returnTo, redirectUri }),
153
+ { maxAge: OAUTH_COOKIE_MAX_AGE_MS }
154
+ ));
155
+ return redirect(authUrl);
156
+ }],
157
+
158
+ ['GET', '/oauth/callback', async (ctx) => {
159
+ const { config, client } = ctx;
160
+ const saved = readOAuthCookie(ctx);
161
+ ctx.cookies.push(clearCookie(config, COOKIE_OAUTH));
162
+
163
+ if (!saved?.nonce || ctx.query('state') !== saved.nonce) {
164
+ return redirectWithError(
165
+ config, 'INVALID_OAUTH_STATE',
166
+ 'This sign-in expired or was started in another browser. Please try again.'
167
+ );
168
+ }
169
+
170
+ if (ctx.query('error')) {
171
+ return redirectWithError(config, ctx.query('error'), ctx.query('error_description'));
172
+ }
173
+
174
+ if (ctx.query('linked') === '1') {
175
+ return redirect(appLocation(config, saved.returnTo, { voult_linked: saved.provider }));
176
+ }
177
+
178
+ const voultCode = ctx.query('voult_code');
179
+ if (typeof voultCode !== 'string') {
180
+ return redirectWithError(config, 'MISSING_OAUTH_CODE', 'Voult did not return a sign-in code.');
181
+ }
182
+
183
+ let result;
184
+ try {
185
+ result = await exchangeOAuthCode(voultCode, { redirectUri: saved.redirectUri }, client);
186
+ } catch (err) {
187
+ return redirectWithError(config, errorCode(err), err.message);
188
+ }
189
+
190
+ if (result.mfaRequired) {
191
+ ctx.cookies.push(setCookie(config, COOKIE_MFA_PENDING, result.mfaPendingToken, { maxAge: MFA_PENDING_MAX_AGE_MS }));
192
+ return redirect(appLocation(config, config.oauth.mfaPath));
193
+ }
194
+
195
+ // The session cookies go out with this redirect (sessionCookies, in the handler).
196
+ return redirect(appLocation(config, saved.returnTo));
197
+ }],
198
+ ];
199
+ }
@@ -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 ADDED
@@ -0,0 +1,204 @@
1
+ import {
2
+ AuthenticationError,
3
+ ENDPOINTS,
4
+ deleteUser,
5
+ disableMfa,
6
+ enableMfa,
7
+ getCurrentUser,
8
+ getLinkedOAuthProviders,
9
+ getMfaStatus,
10
+ regenerateMfaBackupCodes,
11
+ revokeSession,
12
+ setupMfa,
13
+ unlinkOAuthProvider,
14
+ refreshSession,
15
+ resetPassword,
16
+ sendPasswordResetEmail,
17
+ signInWithEmailAndPassword,
18
+ signInWithUsernameAndPassword,
19
+ signOut,
20
+ signUpWithEmailAndPassword,
21
+ signUpWithUsernameAndPassword,
22
+ updateProfile,
23
+ verifyEmail,
24
+ verifyMfaLogin,
25
+ } from '@voult/sdk';
26
+ import { normalizeVoultError } from './errors.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';
35
+
36
+ /**
37
+ * @typedef {import('./handler.js').Context} Context
38
+ * @typedef {import('./handler.js').Route} Route
39
+ */
40
+
41
+ /** @param {unknown} body @param {number} [status] */
42
+ export const json = (body, status = 200) => ({ status, body });
43
+ /** @param {string} location */
44
+ export const redirect = (location) => ({ status: 302, location });
45
+
46
+ /**
47
+ * Only signed-in users get through: renews an expired access cookie from the refresh cookie
48
+ * first, then answers 401 JSON.
49
+ * @param {(ctx: Context) => Promise<unknown>} handler
50
+ */
51
+ export function authenticated(handler) {
52
+ return async (ctx) => {
53
+ await ctx.renew();
54
+ if (!ctx.client.accessToken) {
55
+ const payload = normalizeVoultError(new AuthenticationError('Authentication required'));
56
+ return json(payload, payload.error.status);
57
+ }
58
+ return handler(ctx);
59
+ };
60
+ }
61
+
62
+ /** @param {Context} ctx @param {object} result */
63
+ export function authResult(ctx, result) {
64
+ // Park the MFA pending token server-side too (as hosted OAuth does), so /mfa/verify needs only
65
+ // the code and a page reload doesn't lose the sign-in. The JSON still carries it for older clients.
66
+ if (result?.mfaRequired && result.mfaPendingToken && ctx.config.session.strategy === 'cookie') {
67
+ ctx.cookies.push(setCookie(ctx.config, COOKIE_MFA_PENDING, result.mfaPendingToken, { maxAge: MFA_PENDING_MAX_AGE_MS }));
68
+ }
69
+ return json(toPublicAuthResult(result, ctx.config));
70
+ }
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
+
89
+ /** @type {Route[]} */
90
+ export const authRoutes = [
91
+ ['GET', '/session', async (ctx) => {
92
+ await ctx.renew();
93
+
94
+ if (!ctx.client.accessToken) {
95
+ // A sign-in that stopped at MFA: the page should show the code prompt.
96
+ const mfaPending = Boolean(ctx.read(COOKIE_MFA_PENDING));
97
+ return json({ authenticated: false, user: null, ...(mfaPending && { mfaPending: true }) });
98
+ }
99
+
100
+ const localUser = ctx.client.getCurrentUser();
101
+ if (localUser?.id || localUser?.email) {
102
+ return json({ authenticated: true, user: localUser });
103
+ }
104
+
105
+ try {
106
+ return json({ authenticated: true, user: await getCurrentUser(ctx.client) });
107
+ } catch {
108
+ return json({ authenticated: false, user: null });
109
+ }
110
+ }],
111
+
112
+ ['POST', '/register', async (ctx) => {
113
+ const { email, password, fullName, username } = ctx.body;
114
+ return authResult(ctx, await signUpWithEmailAndPassword(email, password, { fullName, username }, ctx.client));
115
+ }],
116
+
117
+ ['POST', '/username-register', async (ctx) => {
118
+ const { username, password, fullName, email } = ctx.body;
119
+ return authResult(ctx, await signUpWithUsernameAndPassword(username, password, { fullName, email }, ctx.client));
120
+ }],
121
+
122
+ ['POST', '/email-login', async (ctx) => {
123
+ const { email, password } = ctx.body;
124
+ return authResult(ctx, await signInWithEmailAndPassword(email, password, ctx.client));
125
+ }],
126
+
127
+ ['POST', '/username-login', async (ctx) => {
128
+ const { username, password } = ctx.body;
129
+ return authResult(ctx, await signInWithUsernameAndPassword(username, password, ctx.client));
130
+ }],
131
+
132
+ ['POST', '/logout', async (ctx) => json(await signOut(ctx.client))],
133
+
134
+ ['POST', '/sessions/refresh', async (ctx) => {
135
+ if (ctx.body.refreshToken) {
136
+ ctx.client.refreshToken = ctx.body.refreshToken;
137
+ }
138
+ return authResult(ctx, await refreshSession(ctx.client));
139
+ }],
140
+
141
+ ['GET', '/user/me', authenticated(async (ctx) => json({ user: await getCurrentUser(ctx.client) }))],
142
+
143
+ ['PATCH', '/user/me', authenticated(async (ctx) => json(await updateProfile(ctx.body, ctx.client)))],
144
+
145
+ ['POST', '/user/forgot-password', async (ctx) => json(await sendPasswordResetEmail(ctx.body.email, ctx.client))],
146
+
147
+ ['POST', '/user/reset-password', async (ctx) => {
148
+ const { token, appId } = ctx.body;
149
+ const newPassword = ctx.body.newPassword ?? ctx.body.password;
150
+ return json(await resetPassword(token, newPassword, { appId }, ctx.client));
151
+ }],
152
+
153
+ ['GET', '/user/verify-email', async (ctx) => (
154
+ json(await verifyEmail(ctx.query('token'), { appId: ctx.query('appId') }, ctx.client))
155
+ )],
156
+
157
+ ['POST', '/mfa/verify', async (ctx) => {
158
+ const { mfaToken } = ctx.body;
159
+ // Password sign-in hands the token to the page; hosted OAuth keeps it in a cookie.
160
+ const mfaPendingToken = ctx.body.mfaPendingToken ?? ctx.read(COOKIE_MFA_PENDING);
161
+ const result = await verifyMfaLogin(mfaPendingToken, mfaToken, ctx.client);
162
+ ctx.cookies.push(clearCookie(ctx.config, COOKIE_MFA_PENDING));
163
+ return authResult(ctx, result);
164
+ }],
165
+
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
+ })],
204
+ ];