You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/authentication.ts

1783 lines
59 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { nanoid } from 'nanoid'
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthRequestTarget, AuthStrategy } from '../models/authentication.ts'
import type { FastifyInstance, FastifyReply, 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
/**
* The flow a redirect login started, as it is kept for the answer to be checked against.
*
* The same shape the session holds (`types/fastify.d.ts`); named here because a POST-binding provider
* needs it in a cookie as well — see `AUTH_FLOW_COOKIE`.
*/
interface AuthFlowState {
strategyId: string
siteId: string
state: string
nonce: string
codeVerifier: string
redirect: string
startedAt: string
}
/**
* A cookie carrying the same flow, for a provider that answers with a form POST.
*
* SAML's assertion arrives as a cross-site POST from a page at the identity provider, and a browser
* sends no `SameSite=Lax` cookie with one of those — which the session cookie is, so the session that
* started the login is simply not there to check the answer against. This cookie says `SameSite=None`
* instead, which is the only value a cross-site POST carries, and `Secure` because a browser refuses
* that combination otherwise. A wiki serving SAML over plain HTTP therefore falls back to the session
* copy, which works for an identity provider on the same site and not otherwise; SAML over HTTP is
* not a deployment anybody should have.
*
* It is scoped to the callback path and signed, and holds nothing a session cookie would not: the
* `state` in it is what the provider's `RelayState` has to match, exactly as on the query-string
* bindings.
*/
const AUTH_FLOW_COOKIE = 'wikiAuthFlow'
const AUTH_FLOW_COOKIE_PATH = '/_api/auth'
/** How long a logout will wait for a module to say where the provider wants the browser sent. */
const LOGOUT_URL_BUDGET_MS = 5000
/**
* 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()}`
}
/**
* The flow this browser started, from wherever this binding could carry it.
*
* The cookie first, because it is the copy that survives a cross-site POST and is therefore the one
* present exactly when the session's is not. Both hold the same thing, and whichever is read the
* answer still has to match its `state`.
*/
function readAuthFlow(req: FastifyRequest): AuthFlowState | undefined {
const raw = req.cookies[AUTH_FLOW_COOKIE]
if (raw) {
const unsigned = req.unsignCookie(raw)
if (unsigned.valid && unsigned.value) {
try {
return JSON.parse(unsigned.value) as AuthFlowState
} catch {
// -> Not ours, or mangled in transit. The session copy is the remaining chance.
}
}
}
return req.session.authFlow
}
/** Spend the flow, in both places it may be held: one callback per login. */
function clearAuthFlow(req: FastifyRequest, reply: FastifyReply): void {
req.session.authFlow = undefined
if (req.cookies[AUTH_FLOW_COOKIE]) {
reply.clearCookie(AUTH_FLOW_COOKIE, { path: AUTH_FLOW_COOKIE_PATH })
}
}
/**
* What a provider's answer carries, whichever binding brought it.
*
* The two differ only in where the pieces sit: a query string carries `state` and, on a refusal,
* `error`, while a form POST carries the whole assertion in the body and echoes the flow back in a
* field of its own. The module is handed both, and reads the one its protocol uses.
*/
interface CallbackAnswer {
/** The flow identifier as this binding carries it — `state`, or SAML's `RelayState`. */
state?: string
error?: string
errorDescription?: string
query: Record<string, string>
body?: Record<string, string>
}
/**
* Accept a provider's answer and establish the session, whichever binding it arrived on.
*
* Everything about the answer is checked against the flow this browser started: a callback with no
* flow behind it, for another strategy, carrying a different `state`, or long after the login began
* is not this login and is refused without the answer being spent. Ends in a redirect either way —
* to where the login was heading, or to the login screen carrying a code it can put in front of the
* user.
*/
async function finishRedirectLogin(
req: FastifyRequest<{ Params: { strategyId: string } }>,
reply: FastifyReply,
answer: CallbackAnswer
): Promise<FastifyReply> {
const flow = readAuthFlow(req)
const redirect = flow?.redirect ?? '/'
if (
!flow ||
flow.strategyId !== req.params.strategyId ||
!answer.state ||
answer.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`
)
clearAuthFlow(req, reply)
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_EXPIRED'))
}
// -> Spent, whatever happens next: one callback per login
clearAuthFlow(req, reply)
if (answer.error) {
WIKI.models.flags.authDebug(
`Provider refused the login for strategy ${flow.strategyId}: ${answer.error} ${answer.errorDescription ?? ''}`
)
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 query = new URLSearchParams(answer.query).toString()
const profile = await instance.profile({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier,
currentUrl: query
? `${callbackUrl(req, strategy.id)}?${query}`
: callbackUrl(req, strategy.id),
code: answer.query.code,
body: answer.body
})
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))
}
}
/**
* Where the provider that signed a session in wants the browser sent once the wiki has logged it out.
*
* Signing out here destroys the wiki's session and nothing else: the provider still holds its own, so
* the next click on "Login" is answered by an identity provider that already knows who this is and
* signs them straight back in without asking anything. To an administrator that reads as a logout
* that does not work, and on a shared machine it is one.
*
* A module says where by implementing `logoutUrl()` — the OIDC module does, from the URL its strategy
* is configured with, and returns null when there is none. Nothing is guessed for a module that has
* no such notion.
*/
async function providerLogoutUrl(strategyId: string | undefined): Promise<string | null> {
if (!strategyId) {
return null
}
const instance = WIKI.auth.strategies[strategyId] as any
if (typeof instance?.logoutUrl !== 'function') {
return null
}
try {
/*
Bounded, and caught. Answering this can mean reaching the provider — the OIDC module discovers
the endpoint rather than being told it — and a logout is exactly when a provider is plausibly
the thing that has gone down. Node's `fetch` has no timeout of its own, so without a budget an
unreachable issuer would leave somebody who clicked Logout watching a spinner until a socket
gave up. Losing the provider's logout is the lesser failure, and the wiki's own session is
destroyed either way.
*/
const answered = await Promise.race([
instance.logoutUrl(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), LOGOUT_URL_BUDGET_MS))
])
return answered || null
} catch (err: any) {
WIKI.logger.warn(
`Could not resolve the provider logout URL for strategy ${strategyId}: ${err.message}`
)
return null
}
}
/**
* A strategy as it may be sent to a client: everything about it, minus the secrets.
*
* A prop the module declares `sensitive` — an OAuth client secret, an LDAP bind password — is
* write-only, and reads as a mask standing in for whatever is stored. The admin area sends the whole
* configuration back when it saves, and `buildConfig` understands the mask as "leave this alone".
*
* Done here rather than in the model because the model's strategies are the ones a login runs on: the
* secret has to stay in the object the module authenticates with, so this is the last point at which
* it can be taken out. `manage:system` on the route is not a reason to skip it — the secret would
* still end up in a browser's memory, its cache and whatever is on the administrator's screen.
*/
function withoutSecrets(strategy: AuthStrategy): AuthStrategy {
const props = WIKI.models.authentication.getModule(strategy.module)?.props ?? {}
return { ...strategy, config: maskSensitiveProps(props, strategy.config) }
}
/**
* 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')
}
}
}
)
/**
* REGISTER
*
* Self-registration on the login screen, which only the local module offers: everything else that
* creates accounts does it on the way through a successful sign-in at the provider.
*/
app.post<{
Params: { siteId: string }
Body: { strategyId: string; name: string; email: string; password: string }
}>(
'/sites/:siteId/auth/register',
{
config: {
publicAccess: true
},
// -> Public and account-creating; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Register a new account',
description:
'Refused unless the strategy is a local one the site offers and has registration turned on. Answers like the login route does: an account that needed no email confirmation is signed in from here, and one that did gets `verifyEmail` instead, with nothing to continue — the link in the email is what finishes it.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'name', 'email', 'password'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
password: {
type: 'string',
minLength: 8,
maxLength: 255
}
}
},
response: {
200: { $ref: 'AuthLoginResult#' }
}
}
},
async (req, reply) => {
try {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new Error('ERR_INVALID_NAME')
}
const result = await WIKI.models.users.registerUser(
{
siteId: req.params.siteId,
strategyId: req.body.strategyId,
name: req.body.name,
email: req.body.email,
password: req.body.password,
ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId })
},
req
)
if (result.authenticated) {
req.session.authenticated = true
}
return {
ok: true,
...result
}
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Registration failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_REGISTRATION_FAILED')
}
}
)
/**
* CONFIRM AN EMAIL ADDRESS
*
* The other end of the link in a registration email — but not the link itself, which lands on the
* login screen and puts a button in front of the reader. **This has to be a POST that somebody
* pressed**, never a GET the link performs: Outlook's Safe Links and the scanners like it fetch
* every URL in a message before it is delivered, and a GET that confirmed the address would be
* spent by the scanner, leaving the real click with a token that has already been used. A form
* submission from the page is not something a link scanner makes.
*
* Nobody is signed in by it either: the browser reading the mail is not necessarily the one that
* registered, and the password is still needed.
*/
app.post<{ Params: { siteId: string }; Body: { token: string } }>(
'/sites/:siteId/auth/verifyEmail',
{
config: {
publicAccess: true
},
// -> The token is the whole of the secret; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Confirm an email address',
description:
'Consumes the token from a registration email and marks the account verified, so it works once. Deliberately not reachable by GET — see the route comment.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['token'],
properties: {
token: {
type: 'string',
minLength: 1,
maxLength: 255
}
}
},
response: {
200: {
description: 'The address was confirmed',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.verifyUserEmail(req.body.token, req.ip)
return { ok: true }
} catch (err: any) {
WIKI.models.flags.authDebug(`Email confirmation refused: ${err.message}`)
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
return reply.badRequest('ERR_INVALID_VALIDATION_TOKEN')
}
}
)
/**
* REQUEST A PASSWORD RESET
*
* Answers the same way whether or not the address belongs to anybody — see `requestPasswordReset`
* for why. What it does report is the two things that are about this wiki rather than about a user:
* a strategy that does not offer resets, and an instance with no mail server configured.
*/
app.post<{ Params: { siteId: string }; Body: { strategyId: string; email: string } }>(
'/sites/:siteId/auth/forgotPassword',
{
config: {
publicAccess: true
},
// -> Public, and sends mail on demand; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Request a password reset link',
description:
'Succeeds for any address, registered or not: a public form that answered differently would be a way of finding out who has an account here.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['strategyId', 'email'],
properties: {
strategyId: {
type: 'string',
format: 'uuid'
},
email: {
type: 'string',
format: 'email',
maxLength: 255
}
}
},
response: {
200: {
description: 'The request was accepted',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.requestPasswordReset({
siteId: req.params.siteId,
strategyId: req.body.strategyId,
email: req.body.email,
ip: req.ip,
baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId })
})
return { ok: true }
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Password reset request failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_FORGOT_PASSWORD_FAILED')
}
}
)
/**
* SET A NEW PASSWORD FROM A RESET LINK
*
* The token stands for the mailbox rather than for a half-finished login, so unlike the
* change-password route above this one signs nobody in: what comes next is the login screen.
*/
app.post<{ Params: { siteId: string }; Body: { token: string; newPassword: string } }>(
'/sites/:siteId/auth/resetPassword',
{
config: {
publicAccess: true
},
// -> The token is guessable in principle; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Set a new password from a reset link',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['token', 'newPassword'],
properties: {
token: {
type: 'string',
minLength: 1,
maxLength: 255
},
newPassword: {
type: 'string',
minLength: 8,
maxLength: 255
}
}
},
response: {
200: {
description: 'The password was changed',
type: 'object',
properties: {
ok: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
try {
await WIKI.models.users.resetPassword({
token: req.body.token,
newPassword: req.body.newPassword,
ip: req.ip
})
return { ok: true }
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`Password reset rejected: ${err.message}`)
return reply.badRequest(err.message)
}
WIKI.logger.warn(err)
WIKI.models.flags.authDebug(`Password reset failed unexpectedly: ${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<string, any> } }>(
'/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, Entra ID, Google, GitHub, SAML. 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\nA SAML strategy set to the POST request binding has no URL to be sent to, so it answers 200 with the page carrying the form the browser submits to the provider instead.\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: {
200: { description: 'A page that submits the request to the provider', type: 'string' },
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
/*
A module whose provider answers with a form POST needs the flow somewhere a cross-site POST
will carry it, which the session cookie is not. Only for those: every other strategy is
answered on a top-level GET, which the session cookie does accompany, and a `SameSite=None`
cookie is not something to hand out where nothing reads it.
*/
if (WIKI.models.authentication.getModule(strategy.module)?.postCallback) {
reply.setCookie(AUTH_FLOW_COOKIE, JSON.stringify(flow), {
signed: true,
httpOnly: true,
secure: true,
sameSite: 'none',
path: AUTH_FLOW_COOKIE_PATH,
maxAge: AUTH_FLOW_MINUTES * 60
})
}
try {
const target: AuthRequestTarget = await instance.authorizationUrl({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier
})
WIKI.models.flags.authDebug(
`Sending the browser to the ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}`
)
// -> A module with no URL to send the browser to answers with the page that gets it there
// instead; see `AuthRequestTarget`
return typeof target === 'string'
? reply.redirect(target)
: reply.type('text/html; charset=utf-8').send(target.html)
} 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) =>
finishRedirectLogin(req, reply, {
state: req.query.state,
error: req.query.error,
errorDescription: req.query.error_description,
query: req.query as Record<string, string>
})
)
/**
* FINISH A REDIRECT LOGIN, POST BINDING
*/
app.post<{
Params: { strategyId: string }
Body: { SAMLResponse?: string; RelayState?: string }
}>(
'/auth/:strategyId/callback',
{
config: {
publicAccess: true
},
onRequest: limitAuthAttempts,
schema: {
summary: 'Finish a login at an identity provider that answers with a form POST',
description:
"The same callback, for a provider whose answer does not fit on a query string. SAML's assertion arrives this way: as a form the identity provider has the browser submit here, with the flow echoed back in `RelayState`.\n\nSame URL as the GET binding, so an administrator registers one address whichever protocol the strategy speaks.",
tags: ['Authentication'],
consumes: ['application/x-www-form-urlencoded'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
body: {
type: 'object',
additionalProperties: true,
properties: {
SAMLResponse: {
type: 'string',
description: 'The base64-encoded SAML response, as the identity provider posted it.'
},
RelayState: {
type: 'string',
description: 'The flow identifier this login was started with, echoed back.'
}
}
},
response: {
302: { description: 'Redirect back into the wiki', type: 'null' }
}
}
},
async (req, reply) =>
finishRedirectLogin(req, reply, {
state: req.body?.RelayState,
query: {},
body: (req.body ?? {}) as Record<string, string>
})
)
/**
* SERVICE PROVIDER METADATA
*/
app.get<{ Params: { strategyId: string } }>(
'/auth/:strategyId/metadata',
{
config: {
publicAccess: true
},
schema: {
summary: 'Service provider metadata for a strategy',
description:
'The XML description of this wiki as a service provider — its entity ID, where assertions are to be posted, and the certificate it signs requests with — for a protocol whose setup is an exchange of metadata documents rather than of pasted values. SAML strategies offer one; nothing else does, and asking another strategy for it answers 404.\n\nPublic, because it is what is handed to an identity provider, and it contains no secret: a certificate is the public half of a key pair.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
response: {
200: { description: 'The metadata document', type: 'string' }
}
}
},
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?.metadata !== 'function') {
return reply.notFound('This login provider has no metadata to describe it.')
}
return reply
.type('application/samlmetadata+xml')
.send(await instance.metadata({ callbackUrl: callbackUrl(req, strategy.id) }))
}
)
/**
* 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 logout URL of the strategy they signed in with, if its module has one — which is how an identity provider's own session is ended as well — otherwise 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 both halves depend on it.
The provider's own logout comes first when there is one, and takes the group's and the site's
redirect with it: those say where a reader should end up, which is a preference, whereas an
identity provider still holding a session is the logout not having finished. Where the browser
goes after that is the provider's business — it is configured with its own return URL.
*/
const redirect =
(await providerLogoutUrl(req.session?.strategyId)) ??
(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
}
})
// -> Not through `audit()`: the session was destroyed above, so the request no longer knows
// who made it and the actor has to come from the copy taken before that
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'logout',
actor: { id: user.id, name: user.name, email: user.email, ip: req.ip },
meta: { siteId: req.params.siteId }
})
}
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 sites configuration. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret.',
tags: ['Authentication'],
response: {
200: {
description: 'List of configured strategies',
type: 'array',
items: { $ref: 'AuthStrategy#' }
}
}
}
},
async () => {
return (await WIKI.models.authentication.getActiveStrategies()).map(withoutSecrets)
}
)
/**
* 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 withoutSecrets(strategy)
}
)
/**
* CREATE STRATEGY
*/
app.post<{ Body: Record<string, any> }>(
'/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)
// -> The module and the display name, never the config: a strategy's config is where its client
// secret lives
await audit(req, 'admin', 'createAuthStrategy', {
strategyId: id,
module: req.body.module,
displayName: req.body.displayName
})
return {
ok: true,
message: 'Authentication strategy created successfully.',
id
}
}
)
/**
* UPDATE STRATEGY
*/
app.put<{ Params: { strategyId: string }; Body: Record<string, any> }>(
'/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<string, any> = {}
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.')
}
// -> Which fields were touched, not what they were set to, for the same reason as above
await audit(req, 'admin', 'updateAuthStrategy', {
strategyId: current.id,
module: current.module,
displayName: current.displayName,
changedFields: Object.keys(patch)
})
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 sites 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)
await audit(req, 'admin', 'deleteAuthStrategy', {
strategyId: req.params.strategyId,
module: strategy.module,
displayName: strategy.displayName
})
return reply.code(204).send()
}
)
}
export default routes