import { nanoid } from 'nanoid' import { limitAuthAttempts } from '../helpers/rateLimit.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' /** * How long a redirect login may take before its callback is refused. * * Long enough for somebody to be asked for a password and a second factor at the provider, short * enough that a `state` left lying around in a URL somewhere is no longer worth anything. */ const AUTH_FLOW_MINUTES = 15 /** * Where a provider sends the browser back, as an absolute URL. * * Built from the request rather than stored, so an instance reachable on more than one hostname keeps * working — but it has to match what the administrator registered with the provider, which is why the * admin area shows this exact shape on the strategy's page. */ function callbackUrl(req: FastifyRequest, strategyId: string): string { return `${req.protocol}://${req.host}/_api/auth/${strategyId}/callback` } /** * The login screen, carrying what went wrong. * * A redirect login fails at the provider or on the way back, where there is no request left to answer * with an error — so the browser is sent to the login screen with a code it can put in front of the * user, and `redirect` is preserved so that a successful second attempt still lands where the first * one was going. */ function loginErrorUrl(redirect: string, code: string): string { const params = new URLSearchParams({ error: code }) if (redirect && redirect !== '/') { params.set('redirect', redirect) } return `/login?${params.toString()}` } /** * Authentication API Routes */ async function routes(app: FastifyInstance) { /** * GET SITE AUTHENTICATION STRATEGIES */ app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>( '/sites/:siteId/auth/strategies', { config: { publicAccess: true }, schema: { summary: 'List all site authentication strategies', description: 'Ordered by the position configured for the site. `activeStrategy` holds the per-instance settings, nested under it `strategy` holds the module definition.', tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, querystring: { type: 'object', properties: { visibleOnly: { type: 'boolean', default: false } } }, response: { 200: { description: 'List of site authentication strategies', type: 'array', items: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, order: { type: 'integer' }, isVisible: { type: 'boolean' }, activeStrategy: { type: 'object', properties: { displayName: { type: 'string' }, registration: { type: 'boolean' }, allowForgotPassword: { type: 'boolean', description: 'Whether this strategy offers a password reset from the login screen. False for a strategy whose module has no such setting.' }, strategy: { type: 'object', properties: { key: { type: 'string' }, title: { type: 'string' }, icon: { type: 'string' }, color: { type: 'string' }, useForm: { type: 'boolean' }, usernameType: { type: 'string' } } } } } } } } } } }, async (req, reply) => { const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) if (!site) { return reply.badRequest('Invalid Site ID') } /* `getActiveStrategies` rather than the raw rows: it completes each config from the module's declared defaults, so a prop added to a module after a strategy was configured reads as its default here instead of as a missing key. */ const activeStrategies = (await WIKI.models.authentication.getActiveStrategies()).filter( (str: any) => str.isEnabled ) // -> A site created before it had strategies configured has no list at all const configuredStrategies = site.config.authStrategies ?? [] const siteStrategies = activeStrategies .map((str: any) => { const authModule = WIKI.data.authentication.find((m: any) => m.key === str.module) const siteStr = configuredStrategies.find((s: any) => s.id === str.id) || {} return { id: str.id, order: siteStr.order ?? 0, isVisible: siteStr.isVisible ?? false, activeStrategy: { displayName: str.displayName, registration: str.registration, /* Named explicitly, like every other field here: this endpoint is public and a strategy's config is where an OAuth client secret lives, so nothing may reach it by spreading. A module that declares no such prop reads as false, which is correct rather than a default -- a strategy with no password of its own has no password to reset. */ allowForgotPassword: str.config?.allowForgotPassword === true, strategy: { key: authModule?.key ?? str.module, title: authModule?.title ?? str.module, icon: authModule?.icon ?? '', color: authModule?.color ?? 'primary', useForm: authModule?.useForm ?? false, usernameType: authModule?.usernameType ?? 'email' } } } }) .sort((a: any, b: any) => a.order - b.order) return req.query.visibleOnly ? siteStrategies.filter((s: any) => s.isVisible) : siteStrategies } ) /** * LOGIN USING USER/PASS */ app.put<{ Params: { siteId: string } Body: { strategyId: string; username?: string; password?: string } }>( '/sites/:siteId/auth/login', { config: { publicAccess: true }, // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts` onRequest: limitAuthAttempts, schema: { summary: 'Login', tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } } }, body: { type: 'object', required: ['strategyId'], properties: { strategyId: { type: 'string', format: 'uuid' }, username: { type: 'string', minLength: 1, maxLength: 255 }, password: { type: 'string', minLength: 1, maxLength: 255 } } }, response: { 200: { $ref: 'AuthLoginResult#' } } } }, async (req, reply) => { try { const result = await WIKI.models.users.login( { siteId: req.params.siteId, strategyId: req.body.strategyId, username: req.body.username, password: req.body.password, ip: req.ip }, req ) if (!result) { throw new Error('Unexpected empty login response.') } return { ok: true, ...result } } catch (err: any) { if (err.message.startsWith('ERR_')) { return reply.badRequest(err.message) } else { // -> An unexpected failure, reported to the client as a generic one. The detail is behind // the authDebug flag rather than logged on every failed login. WIKI.logger.debug(err) WIKI.models.flags.authDebug(`Login failed unexpectedly: ${err.message}`) return reply.badRequest('ERR_LOGIN_FAILED') } } } ) /** * CHANGE PASSWORD */ app.put<{ Params: { siteId: string } Body: { strategyId: string; continuationToken: string; newPassword: string } }>( '/sites/:siteId/auth/changePassword', { config: { publicAccess: true }, // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts` onRequest: limitAuthAttempts, schema: { summary: 'Change Password From Login', tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } } }, body: { type: 'object', required: ['strategyId', 'continuationToken', 'newPassword'], properties: { strategyId: { type: 'string', format: 'uuid' }, continuationToken: { type: 'string', minLength: 1, maxLength: 255 }, newPassword: { type: 'string', minLength: 1, maxLength: 255 } } }, response: { 200: { $ref: 'AuthLoginResult#' } } } }, async (req, reply) => { try { const result = await WIKI.models.users.loginChangePassword( { siteId: req.params.siteId, strategyId: req.body.strategyId, continuationToken: req.body.continuationToken, newPassword: req.body.newPassword, ip: req.ip }, req ) if (!result) { throw new Error('Unexpected empty change password response.') } if (result?.authenticated) { req.session.authenticated = true } return { ok: true, ...result } } catch (err: any) { if (err.message.startsWith('ERR_')) { WIKI.models.flags.authDebug(`Password change from login rejected: ${err.message}`) return reply.badRequest(err.message) } else { WIKI.logger.debug(err) WIKI.models.flags.authDebug(`Password change from login failed: ${err.message}`) return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED') } } } ) /** * SUBMIT A 2FA CODE * * The other half of a login that answered `provideTfa` or `setupTfa`: the continuation token stands * for the login that got that far, and the code proves the second factor. With `setup`, a correct * code also activates the secret the login generated, which is how an account that is required to * use 2FA gets it configured. */ app.put<{ Params: { siteId: string } Body: { strategyId: string continuationToken: string securityCode: string setup?: boolean } }>( '/sites/:siteId/auth/tfa', { config: { publicAccess: true }, // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts` onRequest: limitAuthAttempts, schema: { summary: 'Submit a 2FA Security Code From Login', description: 'Answers like the login route does, since the same checks continue afterwards: a user who also owes a password change is asked for one next. A wrong code can be retried a few times before the continuation token is discarded and the login has to be started again.', tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, body: { type: 'object', required: ['strategyId', 'continuationToken', 'securityCode'], properties: { strategyId: { type: 'string', format: 'uuid' }, continuationToken: { type: 'string', minLength: 1, maxLength: 255 }, securityCode: { type: 'string', pattern: '^[0-9]{6}$', description: 'The six digits shown by the authenticator app.' }, setup: { type: 'boolean', default: false, description: 'True when answering a `setupTfa` login, i.e. the code confirms a secret that was just generated.' } } }, response: { 200: { $ref: 'AuthLoginResult#' } } } }, async (req, reply) => { try { const result = await WIKI.models.users.loginTFA( { siteId: req.params.siteId, strategyId: req.body.strategyId, continuationToken: req.body.continuationToken, securityCode: req.body.securityCode, setup: req.body.setup ?? false, ip: req.ip }, req ) return { ok: true, ...result } } catch (err: any) { if (err.message.startsWith('ERR_')) { WIKI.models.flags.authDebug(`2FA verification rejected: ${err.message}`) return reply.badRequest(err.message) } else { WIKI.logger.debug(err) WIKI.models.flags.authDebug(`2FA verification failed unexpectedly: ${err.message}`) return reply.badRequest('ERR_TFA_FAILED') } } } ) /** * REQUEST A PASSKEY CHALLENGE * * Takes no identity: a passkey says which account it belongs to, so there is nobody to name until the * assertion comes back. The challenge is remembered on the session. */ app.post<{ Params: { siteId: string } }>( '/sites/:siteId/auth/passkey/challenge', { config: { publicAccess: true }, // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts` onRequest: limitAuthAttempts, schema: { summary: 'Get the options for logging in with a passkey', description: "Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `PUT /sites/:siteId/auth/passkey/login`. No credential list is sent and no user is named: passkeys are registered as discoverable credentials, so the authenticator offers whichever ones it holds for this hostname and the assertion identifies the account.", tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, response: { 200: { description: 'Passkey challenge generated', type: 'object', properties: { ok: { type: 'boolean' }, authOptions: { type: 'object', additionalProperties: true, description: 'A WebAuthn `PublicKeyCredentialRequestOptions`, JSON-encoded.' } } } } } }, async (req, reply) => { try { const { authOptions, pending } = await WIKI.models.passkeys.startLogin({ hostname: req.hostname, origin: req.headers.origin }) req.session.passkeyLogin = pending return { ok: true, authOptions } } catch (err: any) { if (err.message.startsWith('ERR_')) { return reply.badRequest(err.message) } else { WIKI.logger.debug(err) return reply.badRequest('ERR_LOGIN_FAILED') } } } ) /** * LOGIN USING A PASSKEY */ app.put<{ Params: { siteId: string }; Body: { authResponse: Record } }>( '/sites/:siteId/auth/passkey/login', { config: { publicAccess: true }, // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts` onRequest: limitAuthAttempts, schema: { summary: 'Login With a Passkey', description: 'Verifies what the authenticator signed and, if it holds up, logs the user in. A passkey establishes both identity and presence, so no password or 2FA code is asked for on top of it.', tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, body: { type: 'object', required: ['authResponse'], properties: { authResponse: { type: 'object', additionalProperties: true, description: "The browser's WebAuthn authentication response, JSON-encoded." } } }, response: { 200: { $ref: 'AuthLoginResult#' } } } }, async (req, reply) => { try { const result = await WIKI.models.passkeys.verifyLogin( { authResponse: req.body.authResponse as any, pending: req.session.passkeyLogin, ip: req.ip }, req ) return { ok: true, ...result } } catch (err: any) { if (err.message.startsWith('ERR_')) { return reply.badRequest(err.message) } else { WIKI.logger.debug(err) WIKI.models.flags.authDebug(`Passkey login failed unexpectedly: ${err.message}`) return reply.badRequest('ERR_LOGIN_FAILED') } } finally { // -> Spent either way: a rejected assertion does not get a second go at the same challenge req.session.passkeyLogin = undefined } } ) /** * START A REDIRECT LOGIN */ app.get<{ Params: { strategyId: string } Querystring: { siteId?: string; redirect?: string } }>( '/auth/:strategyId/authorize', { config: { publicAccess: true }, schema: { summary: 'Start a login at an identity provider', description: 'Answers with a redirect to the provider, for a strategy whose module signs users in there rather than through a form — OpenID Connect, Google, GitHub. The `state`, `nonce` and PKCE verifier that tie the answer back to this browser are generated here and kept on the session; the browser is never trusted with any of them.\n\nOpened by following the link, not by fetching it: what comes back is a page at the provider.', tags: ['Authentication'], params: { type: 'object', properties: { strategyId: { type: 'string', format: 'uuid' } }, required: ['strategyId'] }, querystring: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' }, redirect: { type: 'string', maxLength: 255, description: 'Where to send the user once they are logged in. A path on this wiki; anything else is ignored.' } } }, response: { 302: { description: 'Redirect to the identity provider', type: 'null' } } } }, async (req, reply) => { const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId) const instance = WIKI.auth.strategies[req.params.strategyId] as any if (!strategy?.isEnabled || typeof instance?.authorizationUrl !== 'function') { return reply.notFound('There is no such login provider.') } const siteId = req.query.siteId ?? WIKI.sitesMappings[req.hostname] ?? '' const flow = { strategyId: strategy.id, siteId, state: nanoid(32), nonce: nanoid(32), codeVerifier: nanoid(64), // -> Only a path on this wiki: an open redirect is how a login page is turned into a lure redirect: (req.query.redirect ?? '').startsWith('/') ? req.query.redirect! : '/', startedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }) } req.session.authFlow = flow try { const url = await instance.authorizationUrl({ redirectUri: callbackUrl(req, strategy.id), state: flow.state, nonce: flow.nonce, codeVerifier: flow.codeVerifier }) WIKI.models.flags.authDebug( `Redirecting to ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}` ) return reply.redirect(url) } catch (err: any) { WIKI.logger.warn(`Could not start a login at ${strategy.module}: ${err.message}`) return reply.redirect(loginErrorUrl(flow.redirect, err.message)) } } ) /** * FINISH A REDIRECT LOGIN */ app.get<{ Params: { strategyId: string } Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>( '/auth/:strategyId/callback', { config: { publicAccess: true }, // -> A callback is a password check by another name: whatever it carries decides who is logged in onRequest: limitAuthAttempts, schema: { summary: 'Finish a login at an identity provider', description: "Where the provider sends the browser back. The answer is only accepted if it matches the flow this session started — same strategy, same `state`, and within the time a login takes — after which the module turns the code into an account and the session is established. Ends in a redirect either way: to where the login was heading, or to the login screen carrying an error code.\n\nThis is the URL an administrator registers with the provider; it is shown on the strategy's own page in the admin area.", tags: ['Authentication'], params: { type: 'object', properties: { strategyId: { type: 'string', format: 'uuid' } }, required: ['strategyId'] }, response: { 302: { description: 'Redirect back into the wiki', type: 'null' } } } }, async (req, reply) => { const flow = req.session.authFlow const redirect = flow?.redirect ?? '/' /* Everything about the answer is checked against the flow this session started. A callback that arrives with no flow behind it, for another strategy, with a different `state`, or long after the login began is not this session's login — and is refused without the code being spent. */ if ( !flow || flow.strategyId !== req.params.strategyId || !req.query.state || req.query.state !== flow.state || Temporal.Instant.compare( Temporal.Instant.from(flow.startedAt).add({ minutes: AUTH_FLOW_MINUTES }), Temporal.Now.instant() ) < 0 ) { WIKI.models.flags.authDebug( `Callback for strategy ${req.params.strategyId} from ${req.ip} did not match this session's login` ) req.session.authFlow = undefined return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_EXPIRED')) } // -> Spent, whatever happens next: one callback per login req.session.authFlow = undefined if (req.query.error) { WIKI.models.flags.authDebug( `Provider refused the login for strategy ${flow.strategyId}: ${req.query.error} ${req.query.error_description ?? ''}` ) return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED')) } const strategy = await WIKI.models.authentication.getStrategyById(flow.strategyId) const instance = WIKI.auth.strategies[flow.strategyId] as any if (!strategy?.isEnabled || typeof instance?.profile !== 'function') { return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED')) } try { const profile = await instance.profile({ redirectUri: callbackUrl(req, strategy.id), state: flow.state, nonce: flow.nonce, codeVerifier: flow.codeVerifier, currentUrl: `${callbackUrl(req, strategy.id)}?${new URLSearchParams(req.query as Record).toString()}`, code: req.query.code }) const result = await WIKI.models.users.loginWithProvider( { siteId: flow.siteId, strategy, profile, ip: req.ip }, req ) return reply.redirect(result.redirect || redirect) } catch (err: any) { WIKI.models.flags.authDebug( `Login through ${strategy.module} strategy ${strategy.id} failed: ${err.message}` ) return reply.redirect(loginErrorUrl(redirect, err.message)) } } ) /** * LOGOUT */ app.post<{ Params: { siteId: string } }>( '/sites/:siteId/auth/logout', { config: { publicAccess: true }, schema: { summary: 'Logout', description: "Destroys the current session and answers with where to send the user next: the first of the user's groups that sets a logout redirect, otherwise the site's own setting, otherwise the site root. A request that was not logged in gets the same answer rather than an error, so that a client acting on a session the server has already forgotten still ends up somewhere sensible.", tags: ['Authentication'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, response: { 200: { description: 'Logged out successfully', type: 'object', properties: { ok: { type: 'boolean' }, redirect: { type: 'string', description: 'A path within this wiki, or an absolute URL if one is configured.' } } } } } }, async (req, reply) => { const user = req.session?.authenticated ? req.session.user : null // -> Resolved before the session goes away, since it depends on who was logged in const redirect = await WIKI.models.users.getLogoutRedirect( user?.id ?? null, req.params.siteId ) if (req.session) { // -> Drops the stored session, so the cookie the browser still holds refers to nothing await req.session.destroy() } // -> And clear that cookie too: `destroy()` detaches the session, which leaves the plugin's own // save hook with nothing to do. Name and options match the registration in `index.ts`. reply.clearCookie('wikiSession') if (user) { WIKI.models.flags.authDebug( `User ${user.id} <${user.email}> logged out, redirecting to ${redirect}` ) await WIKI.models.hooks.emit('user:logout', { userId: user.id, ip: req.ip, metadata: { name: user.name, email: user.email } }) } return { ok: true, redirect } } ) /** * LIST AUTHENTICATION MODULES */ app.get( '/authentication/modules', { config: { permissions: ['manage:system'] }, schema: { summary: 'List the authentication modules available on this server', description: 'Read from `modules/authentication` at startup, so installing a module means dropping it on disk and restarting. Modules that declare themselves unavailable are not listed.', tags: ['Authentication'], response: { 200: { description: 'List of authentication modules', type: 'array', items: { $ref: 'AuthModule#' } } } } }, async () => { return WIKI.models.authentication.getModules() } ) /** * LIST CONFIGURED STRATEGIES */ app.get( '/authentication/strategies', { config: { permissions: ['manage:system'] }, schema: { summary: 'List the configured authentication strategies', description: 'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. Configuration values include any secrets a module stores, hence the `manage:system` requirement.', tags: ['Authentication'], response: { 200: { description: 'List of configured strategies', type: 'array', items: { $ref: 'AuthStrategy#' } } } } }, async () => { return WIKI.models.authentication.getActiveStrategies() } ) /** * GET CONFIGURED STRATEGY */ app.get<{ Params: { strategyId: string } }>( '/authentication/strategies/:strategyId', { config: { permissions: ['manage:system'] }, schema: { summary: 'Get a single configured authentication strategy', tags: ['Authentication'], params: { type: 'object', properties: { strategyId: { type: 'string', format: 'uuid' } }, required: ['strategyId'] }, response: { 200: { $ref: 'AuthStrategy#' } } } }, async (req, reply) => { const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId) if (!strategy) { return reply.notFound('Authentication strategy does not exist.') } return strategy } ) /** * CREATE STRATEGY */ app.post<{ Body: Record }>( '/authentication/strategies', { config: { permissions: ['manage:system'] }, schema: { summary: 'Configure a new authentication strategy', description: 'A module can be configured more than once, so that two instances of the same provider can coexist. A new strategy is not offered by any site until that site adds it to its login screen.', tags: ['Authentication'], body: { allOf: [{ $ref: 'AuthStrategyInput#' }, { type: 'object', required: ['module'] }] }, response: { 200: { description: 'Strategy created successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, id: { type: 'string', format: 'uuid' } } } } } }, async (req, reply) => { const mod = WIKI.models.authentication.getModule(req.body.module) if (!mod) { return reply.badRequest(`There is no authentication module named "${req.body.module}".`) } const invalid = (await WIKI.models.authentication.validateStrategy({ module: req.body.module, displayName: req.body.displayName, isEnabled: req.body.isEnabled, allowedEmailRegex: req.body.allowedEmailRegex, autoEnrollGroups: req.body.autoEnrollGroups })) ?? WIKI.models.authentication.validateConfig(req.body.module, req.body.config) if (invalid) { return reply.badRequest(invalid) } const id = await WIKI.models.authentication.createStrategy(req.body as any) return { ok: true, message: 'Authentication strategy created successfully.', id } } ) /** * UPDATE STRATEGY */ app.put<{ Params: { strategyId: string }; Body: Record }>( '/authentication/strategies/:strategyId', { config: { permissions: ['manage:system'] }, schema: { summary: 'Update an authentication strategy', description: 'Accepts any subset of the fields, except `module`, which is fixed once a strategy exists. The strategies are reloaded on success, so a configuration change applies to the next login rather than after a restart.', tags: ['Authentication'], params: { type: 'object', properties: { strategyId: { type: 'string', format: 'uuid' } }, required: ['strategyId'] }, body: { $ref: 'AuthStrategyInput#' }, response: { 200: { description: 'Strategy updated successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const current = await WIKI.models.authentication.getStrategyById(req.params.strategyId) if (!current) { return reply.notFound('Authentication strategy does not exist.') } if (req.body.module !== undefined && req.body.module !== current.module) { return reply.badRequest('The module of an existing strategy cannot be changed.') } const patch: Record = {} for (const field of [ 'displayName', 'isEnabled', 'registration', 'allowedEmailRegex', 'autoEnrollGroups', 'config' ] as const) { if (req.body[field] !== undefined) { patch[field] = req.body[field] } } if (Object.keys(patch).length < 1) { return reply.badRequest('No strategy fields provided to update.') } const invalid = (await WIKI.models.authentication.validateStrategy({ id: current.id, module: current.module, ...patch })) ?? WIKI.models.authentication.validateConfig(current.module, patch.config) if (invalid) { return reply.badRequest(invalid) } if (!(await WIKI.models.authentication.updateStrategy(req.params.strategyId, patch))) { return reply.internalServerError('Failed to update the authentication strategy.') } return { ok: true, message: 'Authentication strategy updated successfully.' } } ) /** * DELETE STRATEGY */ app.delete<{ Params: { strategyId: string } }>( '/authentication/strategies/:strategyId', { config: { permissions: ['manage:system'] }, schema: { summary: 'Delete an authentication strategy', description: 'Also removes it from every site’s login screen. The built-in local strategy cannot be deleted: every account stores its password under that strategy ID, so removing it would leave no way in.', tags: ['Authentication'], params: { type: 'object', properties: { strategyId: { type: 'string', format: 'uuid' } }, required: ['strategyId'] }, response: { 204: { description: 'Strategy deleted successfully' } } } }, async (req, reply) => { const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId) if (!strategy) { return reply.notFound('Authentication strategy does not exist.') } if (strategy.id === WIKI.data.systemIds.localAuthId) { return reply.conflict('The built-in local strategy cannot be deleted.') } await WIKI.models.authentication.deleteStrategy(req.params.strategyId) return reply.code(204).send() } ) } export default routes