feat: add entra,ldap,saml + map groups + various auth fixes

scarlett
NGPixel 3 days ago
parent 7ea92a982b
commit 0c8108d580
No known key found for this signature in database

@ -2,8 +2,8 @@ import { nanoid } from 'nanoid'
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthStrategy } from '../models/authentication.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
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.
@ -13,6 +13,43 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
*/
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.
*
@ -40,6 +77,168 @@ function loginErrorUrl(redirect: string, code: string): string {
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.
*
@ -956,7 +1155,7 @@ async function routes(app: FastifyInstance) {
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.',
'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',
@ -978,6 +1177,7 @@ async function routes(app: FastifyInstance) {
}
},
response: {
200: { description: 'A page that submits the request to the provider', type: 'string' },
302: { description: 'Redirect to the identity provider', type: 'null' }
}
}
@ -1002,17 +1202,38 @@ async function routes(app: FastifyInstance) {
}
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 url = await instance.authorizationUrl({
const target: AuthRequestTarget = 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}`
`Sending the browser to the ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}`
)
return reply.redirect(url)
// -> 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))
@ -1051,66 +1272,103 @@ async function routes(app: FastifyInstance) {
}
}
},
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
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>
})
)
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'))
/**
* 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>
})
)
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'))
/**
* 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' }
}
}
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<string, string>).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))
},
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) }))
}
)
@ -1126,7 +1384,7 @@ async function routes(app: FastifyInstance) {
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.",
"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',
@ -1158,11 +1416,17 @@ async function routes(app: FastifyInstance) {
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
)
/*
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

@ -124,8 +124,9 @@ async function routes(app: FastifyInstance) {
}
},
async (req, reply) => {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new CustomError('groupCreateInvalidName', 'Invalid Group Name')
const invalid = await WIKI.models.groups.validateName(req.body.name)
if (invalid) {
throw new CustomError('groupCreateInvalidName', invalid)
}
try {
@ -295,6 +296,15 @@ async function routes(app: FastifyInstance) {
throw new CustomError('groupUpdateEmpty', 'No group fields provided to update.')
}
// -> A rename is held to the same rules as a new name, this group excepted: resending the name
// it already has is how a client that edits the whole group at once saves anything else
if (patch.name !== undefined) {
const invalidName = await WIKI.models.groups.validateName(patch.name, group.id)
if (invalidName) {
throw new CustomError('groupUpdateInvalidName', invalidName)
}
}
// -> The root administrators group must keep its permissions, or the instance becomes
// unmanageable with no way to grant `manage:system` back. Resending the current set is
// allowed, so that a client editing other fields can still submit the whole group.

@ -2118,6 +2118,8 @@
"editor.unsaved.title": "Discard Unsaved Changes?",
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
"error.ERR_ACCOUNT_ALREADY_EXISTS": "An account already exists for that email address.",
"error.ERR_ACCOUNT_NOT_ALLOWED": "That account is not in a domain or organization this wiki allows. If you have a separate work account, try that one instead.",
"error.ERR_ACR_NOT_SATISFIED": "Your identity provider did not confirm the kind of sign-in this wiki requires. A stronger sign-in method may be available; otherwise ask an administrator.",
"error.ERR_CHANGE_PASSWORD_FAILED": "The password could not be changed.",
"error.ERR_EMAIL_NOT_ALLOWED": "That email address is not allowed to sign in through this provider.",
"error.ERR_EMAIL_NOT_VERIFIED": "The provider has not verified that email address.",

@ -17,6 +17,14 @@ export interface AuthModule {
color?: string
isAvailable: boolean
useForm: boolean
/**
* Whether the provider answers by having the browser POST a form to the callback rather than by
* sending it back with a query string. SAML does; nothing else here does.
*
* The framework reads it in one place `api/authentication.ts` gives such a strategy's login flow
* a cookie a cross-site POST will actually carry.
*/
postCallback?: boolean
usernameType: string
props: Record<string, ModuleProp>
refs?: Record<string, { title?: string; hint?: string; icon?: string; value: string }>
@ -39,12 +47,26 @@ export interface AuthFlow {
codeVerifier: string
}
/**
* Where a redirect login sends the browser to sign in.
*
* A URL for every protocol that has one, which is nearly all of them. SAML's other request binding
* has no URL to give: the AuthnRequest is a form the browser submits to the identity provider, so a
* module using it answers with the page carrying that form and the route sends it as the response.
*/
export type AuthRequestTarget = string | { html: string }
/** The same flow, once the provider has come back with an answer. */
export interface AuthFlowCallback extends AuthFlow {
/** The callback URL as it arrived, query string included — what an OIDC library validates against. */
currentUrl: string
/** The authorization code, for a module that reads it directly rather than through a library. */
code?: string
/**
* The form the provider had the browser post, for a module whose protocol answers that way a
* SAML assertion is far too big for a query string. Absent on the query-string bindings.
*/
body?: Record<string, string>
}
/**
@ -58,6 +80,30 @@ export interface ProviderProfile {
id: string
email: string
name: string
/**
* Absolute URL of the person's picture at the provider, when it offers one and the module is
* configured to take it. Fetched and stored as the account's avatar, once per URL.
*/
picture?: string
/**
* The picture itself, for a provider that holds the bytes rather than a link to them a directory
* with a `jpegPhoto` attribute. Stored as the avatar, once per distinct image. Takes precedence
* over `picture`, since a module offering both has already read the one it means.
*/
pictureData?: Buffer
/**
* The groups the provider says this person is in, by name.
*
* Absent when the module does not map groups at all, which is not the same as an empty array
* that is the provider naming none, and with `groupsExclusive` set it costs the user every
* mapped membership they had.
*/
groups?: string[]
/**
* Whether `groups` is the whole truth about this person's membership. Set, a group the claim does
* not name is taken away again; unset, the claim only ever adds.
*/
groupsExclusive?: boolean
}
/** A configured instance of an authentication module. */

@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
import { and, count, eq, ilike, ne, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
@ -265,12 +265,47 @@ class Groups {
* @param name Group name
* @returns The new group's ID
*/
/**
* Check a group name, on its way in or on its way to replacing one.
*
* A name has to be unique because it is an identity, not a label: a group is picked by name in the
* group editor, in a strategy's auto-enrollment list and since the identity-provider modules map
* groups by whatever a directory or a claim calls it. Two groups answering to one name make every
* one of those ambiguous, and the group mapping resolves the ambiguity by putting the user in both.
*
* Compared case-insensitively and with the ends trimmed, because "editors" and "Editors " are the
* same name to everybody reading the screen, and a check that let them coexist would be a check
* anybody could step around by holding down the space bar.
*
* @param name The name being asked for
* @param exceptId The group being renamed, which does not clash with itself
* @returns The reason it cannot be used, or null when it is fine
*/
async validateName(name: string, exceptId?: string): Promise<string | null> {
const trimmed = name.trim()
if (trimmed.length < 1) {
return 'The group name cannot be empty.'
}
if (!/^[^<>"]+$/.test(trimmed)) {
return 'The group name cannot contain <, > or ".'
}
const sameName = sql`lower(${groupsTable.name}) = lower(${trimmed})`
const clash = await WIKI.db
.select({ name: groupsTable.name })
.from(groupsTable)
.where(exceptId ? and(sameName, ne(groupsTable.id, exceptId)) : sameName)
.limit(1)
return clash.length > 0 ? `There is already a group named "${clash[0].name}".` : null
}
async createGroup(name: string): Promise<string> {
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
const result = await WIKI.db
.insert(groupsTable)
.values({
name,
// -> Trimmed here rather than at the boundary, so that what is stored is what `validateName`
// compared and no route can store a name that would not have passed
name: name.trim(),
permissions: startingPermissions,
rules: [
{
@ -331,7 +366,11 @@ class Groups {
async updateGroup(id: string, patch: GroupPatch): Promise<boolean> {
const result = await WIKI.db
.update(groupsTable)
.set({ ...this.clampGuestPatch(id, patch), updatedAt: sql`now()` })
.set({
...this.clampGuestPatch(id, patch),
...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
updatedAt: sql`now()`
})
.where(eq(groupsTable.id, id))
await this.reloadCache()
return (result.rowCount ?? 0) > 0

@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import bcrypt from 'bcryptjs'
import QRCode from 'qrcode'
import {
@ -152,6 +153,15 @@ const profilePrefsKeys = ['timezone', 'dateFormat', 'timeFormat', 'appearance',
*/
const avatarSize = 180
/**
* How much of a picture an identity provider points at is worth downloading before giving up on it.
*
* The bytes are on their way to becoming a 180px square, so this is not a quality ceiling it is the
* point past which a claim is pointing at something that was never an avatar, and the wiki should not
* be holding it in memory to find out.
*/
const remoteAvatarLimit = 5 * 1024 * 1024
/**
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
* filter is matched literally. Values are still parameterized by the driver this is about a `%`
@ -725,6 +735,76 @@ class Users {
.where(eq(usersTable.id, userId))
}
/**
* Fetch the picture an identity provider pointed at, and store it as the account's avatar.
*
* Nothing about the URL is taken on trust beyond its being one. It arrived in a claim, so only
* http(s) is followed a `data:` or `file:` URL is not something to go and read on a login the
* download is capped and given a deadline, and the bytes have to sniff as an image exactly as an
* uploaded avatar's do. A provider that answers with a login page instead of a picture is a
* provider whose HTML must not end up served back as somebody's avatar.
*
* Failure is never the login's problem: an avatar that could not be fetched is logged and the
* person is signed in without it.
*
* @returns Whether an avatar was stored
*/
async setAvatarFromUrl(userId: string, url: string): Promise<boolean> {
try {
const target = new URL(url)
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
throw new Error(`a ${target.protocol} URL is not one to fetch a picture from`)
}
const resp = await fetch(target, { redirect: 'follow', signal: AbortSignal.timeout(10_000) })
if (!resp.ok) {
throw new Error(`the server answered ${resp.status}`)
}
if (!resp.body) {
throw new Error('the server answered with nothing')
}
// -> Read with a running total rather than in one go: `content-length` is the sender's claim
// about the size, and this is a body from somewhere an administrator merely named
const chunks: Buffer[] = []
let size = 0
for await (const chunk of resp.body as unknown as AsyncIterable<Uint8Array>) {
size += chunk.byteLength
if (size > remoteAvatarLimit) {
throw new Error(`it is larger than ${Math.round(remoteAvatarLimit / 1024 / 1024)} MB`)
}
chunks.push(Buffer.from(chunk))
}
const data = Buffer.concat(chunks)
if (!detectImageMime(data)) {
throw new Error('what came back is not a PNG, JPEG, WebP or GIF')
}
await this.setAvatar(userId, data)
return true
} catch (err: any) {
WIKI.logger.warn(`Could not store the avatar at ${url} for user ${userId}: ${err.message}`)
return false
}
}
/**
* Store bytes an identity provider handed over directly as the account's avatar.
*
* The same check as an upload and as a fetched picture: what a directory calls `jpegPhoto` is
* whatever was put in the attribute, and only an image may be stored and served back. Failure is
* logged rather than raised, so a login is never lost over an avatar.
*
* @returns Whether an avatar was stored
*/
async setAvatarFromBytes(userId: string, data: Buffer): Promise<boolean> {
if (!detectImageMime(data)) {
WIKI.logger.warn(
`Could not store the avatar for user ${userId}: the provider's bytes are not a PNG, JPEG, WebP or GIF`
)
return false
}
await this.setAvatar(userId, data)
return true
}
/**
* Remove a user's avatar, leaving it to be rendered as initials again.
*/
@ -806,6 +886,62 @@ class Users {
}
}
/**
* Put a user in the wiki groups an identity provider named for them.
*
* Groups are matched by name, ignoring case, and only ever matched: a claim naming a group this
* wiki does not have is not an instruction to create one. A group here is a set of permissions and
* page rules that somebody wrote deliberately, and an empty one created from a directory entry
* would grant nothing while looking like it grants something.
*
* What the claim does NOT name is the half worth being careful about. Without `groupsExclusive` the
* claim only ever adds, so a membership granted here survives a directory that has never heard of
* it. With it, the provider is the authority and a group it stops naming is taken back except the
* strategy's auto-enroll groups, which are granted here to everybody this strategy lets in, so a
* claim not mentioning them is not an opinion about them.
*
* A provider whose claim is missing entirely leaves membership alone; one whose claim is an empty
* list has said this person is in none, which under `groupsExclusive` is a removal.
*/
async applyProviderGroups(
userId: string,
profile: ProviderProfile,
strategy: AuthStrategy
): Promise<void> {
if (!profile.groups) {
return
}
const claimed = profile.groups.map((name) => name.toLowerCase())
const all = await WIKI.db
.select({ id: groupsTable.id, name: groupsTable.name })
.from(groupsTable)
const matched = all.filter((grp) => claimed.includes(grp.name.toLowerCase()))
const unmatched = profile.groups.filter(
(name) => !all.some((grp) => grp.name.toLowerCase() === name.toLowerCase())
)
if (unmatched.length > 0) {
WIKI.models.flags.authDebug(
`Strategy ${strategy.id} named ${unmatched.length} group(s) this wiki does not have, for user ${userId}: ${unmatched.join(', ')}`
)
}
const current = await this.getUserGroupIds(userId)
const autoEnroll = strategy.autoEnrollGroups ?? []
const wanted = profile.groupsExclusive
? uniq([...matched.map((grp) => grp.id), ...current.filter((id) => autoEnroll.includes(id))])
: uniq([...current, ...matched.map((grp) => grp.id)])
// -> Every login goes through here, and most of them change nothing: `setUserGroups` replaces the
// whole membership, which is not worth doing to arrive back where it started
if (wanted.length === current.length && wanted.every((id) => current.includes(id))) {
return
}
await this.setUserGroups(userId, wanted)
WIKI.models.flags.authDebug(
`Set user ${userId} to ${wanted.length} group(s) from strategy ${strategy.id}'s group claim`
)
}
/**
* Update the local-strategy behaviour flags for a user, leaving secrets and any other linked
* provider untouched.
@ -1222,10 +1358,20 @@ class Users {
`Login attempt on site ${siteId} using ${str.module} strategy ${strategyId}${username ? ` as "${username}"` : ''} from ${ip}`
)
// Authenticate
let user
/*
Two kinds of form module, told apart by which method they implement.
The local module holds the credential it checks, so it answers with a user of this wiki and
the post-login checks run on it directly. A directory module LDAP checks the credential
somewhere else and answers with a `ProviderProfile` instead, exactly as a redirect login
does, so the account behind it is matched or created by `loginWithProvider` along with its
groups and its avatar. `profile()` is what says which: sniffing the answer could not, since a
user row and a profile both carry an id, an email and a name.
*/
const usesProfile = Boolean(strInfo.useForm) && typeof str.profile === 'function'
let authenticated
try {
user = await str.authenticate(context)
authenticated = usesProfile ? await str.profile(context) : await str.authenticate(context)
} catch (err: any) {
WIKI.models.flags.authDebug(
`Strategy ${str.module} rejected the attempt${username ? ` for "${username}"` : ''}: ${err.message}`
@ -1233,9 +1379,22 @@ class Users {
throw err
}
if (usesProfile) {
// -> The stored row, not `str`: registration, the email allow-list and the auto-enroll
// groups are the strategy's, and the live instance is only the module
const strategy = await WIKI.models.authentication.getStrategyById(strategyId)
if (!strategy) {
throw new Error('ERR_INVALID_STRATEGY')
}
return this.loginWithProvider(
{ siteId, strategy, profile: authenticated as ProviderProfile, ip },
req
)
}
// Perform post-login checks
return this.afterLoginChecks(
user,
authenticated,
strategyId,
context,
{
@ -1332,17 +1491,41 @@ class Users {
through this strategy.
*/
const auth = (user.auth ?? {}) as Record<string, any>
auth[strategy.id] = {
...auth[strategy.id],
id: profile.id,
email
const link = { ...auth[strategy.id], id: profile.id, email }
/*
The avatar is stored only when this link has not already been given the same one. An avatar is a
180px square that will not have changed since the last sign-in, and a login is not where to
spend a round trip or an image decode establishing that. What is remembered is the URL for a
provider that links to the picture and a digest for one that hands the bytes over, and either
way it is recorded only once the image is stored, so a provider that was unreachable is tried
again next time rather than written off.
*/
const pictureRef = profile.pictureData
? `sha256:${createHash('sha256').update(profile.pictureData).digest('hex')}`
: profile.picture
if (pictureRef && pictureRef !== link.picture) {
const stored = profile.pictureData
? await this.setAvatarFromBytes(user.id, profile.pictureData)
: await this.setAvatarFromUrl(user.id, profile.picture!)
if (stored) {
link.picture = pictureRef
// -> The account menu reads this off the session, which is built from `user` below
user.hasAvatar = true
}
}
auth[strategy.id] = link
user.auth = auth
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
// -> After the account exists and before `afterLoginChecks`, which reads the memberships back out
// to resolve this session's permissions and its redirect
await this.applyProviderGroups(user.id, profile, strategy)
/*
Neither 2FA nor a password change is asked for: both are the local strategy's, and this user has
just proved who they are somewhere else where whatever second factor that provider enforces has
@ -1489,7 +1672,7 @@ class Users {
}
// Set Session Data
this.updateSession(user, req)
this.updateSession(user, strategyId, req)
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> logged in with ${user.groups.length} group(s) and ${req?.session?.permissions?.length ?? 0} permission(s), redirecting to ${redirect}`
@ -2161,8 +2344,10 @@ class Users {
})
}
updateSession(user: any, req: any): void {
updateSession(user: any, strategyId: string, req: any): void {
req.session.authenticated = true
// -> Kept for the logout, which has to ask this strategy's module where to send the browser next
req.session.strategyId = strategyId
req.session.user = {
id: user.id,
email: user.email,

@ -0,0 +1,156 @@
import * as client from 'openid-client'
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
/** Where a tenant's OpenID Connect metadata lives. `{tenant}` is the one thing configurable about it. */
const ISSUER_TEMPLATE = 'https://login.microsoftonline.com/{tenant}/v2.0'
/**
* The tenant placeholders Entra accepts and this module does not.
*
* They are what makes an app registration multi-tenant, and a multi-tenant login is a different
* thing from the one being offered here: the ID token would then be accepted from ANY Entra
* directory, so anybody with a Microsoft account anywhere could present a valid token for this
* wiki. Restricting that means checking the issuer against a list of tenants the wiki accepts, which
* is a feature and not a default. Discovery would not carry it off either the metadata for these
* answers with a literal `{tenantid}` in the `issuer` field, which no token ever matches.
*/
const MULTI_TENANT = ['common', 'organizations', 'consumers']
/**
* Microsoft Entra ID (formerly Azure Active Directory)
*
* Entra is an OpenID Connect provider, so this is the generic flow with the issuer built from the
* tenant. What is worth saying about Entra specifically is what its tokens carry, because that is
* where a working configuration is usually lost:
*
* - the email address is in `email` only if the account has a Mail attribute or the tenant maps the
* optional claim, and is in `preferred_username` otherwise, so which claim to read is a setting;
* - the groups claim carries object IDs rather than names unless the tenant is synced from Active
* Directory, which is a thing about the directory and not about this module;
* - there is no picture claim at all, so an avatar arrives only from a tenant that maps one.
*
* Written against `openid-client` for the reason the generic module is: the ID token has to be
* verified against the tenant's published keys, and a token nobody verified still logs somebody in.
*/
export default class EntraAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
/** The tenant as `openid-client` sees it. One discovery round trip, kept for every login after. */
private config: client.Configuration | null = null
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
private async configuration(): Promise<client.Configuration> {
if (this.config) {
return this.config
}
const { tenantId, clientId, clientSecret } = this.conf
if (!tenantId || !clientId || !clientSecret) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
if (MULTI_TENANT.includes(String(tenantId).toLowerCase())) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
this.config = await client.discovery(
new URL(ISSUER_TEMPLATE.replace('{tenant}', encodeURIComponent(tenantId))),
clientId,
clientSecret
)
return this.config
}
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
const config = await this.configuration()
return client
.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256'
})
.toString()
}
async profile({
currentUrl,
state,
nonce,
codeVerifier
}: AuthFlowCallback): Promise<ProviderProfile> {
const config = await this.configuration()
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
expectedState: state,
expectedNonce: nonce,
pkceCodeVerifier: codeVerifier
})
const claims = tokens.claims()
if (!claims?.sub) {
throw new Error('ERR_NO_ID_TOKEN')
}
/*
The userinfo endpoint is asked as well as the token read, because a tenant that emits the group
claim only "as a distributed claim" which is what a token past 200 groups gets keeps it
behind there. `fetchUserInfo` checks the answer is about the same subject.
*/
let info: Record<string, any> = claims
if (config.serverMetadata().userinfo_endpoint) {
info = {
...claims,
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
}
}
const email = info[this.conf.emailClaim || 'email']
if (!email || typeof email !== 'string') {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
return {
// -> `oid` is the account's identifier within the tenant and `sub` is its identifier for this
// one application. `sub` is the one to link by: it is what the ID token was verified as
// being about, and it is stable for as long as the app registration is
id: claims.sub,
email,
name: (info[this.conf.displayNameClaim || 'name'] as string) || email,
picture: this.pictureFrom(info),
...(this.conf.mapGroups === true
? {
groups: this.groupsFrom(info),
groupsExclusive: this.conf.unassignMissingGroups === true
}
: {})
}
}
/**
* The URL of the account's picture, for a tenant that maps a claim carrying one.
*
* Empty by default, and an empty claim name turns it off Entra emits nothing of the sort on its
* own, and a person's photo in Entra is behind Microsoft Graph rather than in a token.
*/
private pictureFrom(info: Record<string, any>): string | undefined {
const claim = this.conf.pictureClaim
if (!claim) {
return undefined
}
const value = info[claim]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/** The group names — or, as Entra usually has it, the group object IDs — the claim carries. */
private groupsFrom(info: Record<string, any>): string[] {
const value = info[this.conf.groupsClaim || 'groups']
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
return raw
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim())
}
}

@ -0,0 +1,82 @@
key: entra
title: Microsoft Entra ID
description: Microsoft Entra ID (formerly Azure Active Directory) is Microsoft's cloud-based identity and access management service.
author: requarks.io
logo: https://static.requarks.io/logo/azure.svg
icon: /_assets/icons/ultraviolet-azure.svg
color: blue-7
isAvailable: true
useForm: false
usernameType: email
props:
tenantId:
type: String
title: Directory (tenant) ID
hint: The tenant this wiki signs people in from — its GUID, or one of its verified domains. From the app registration's Overview page.
icon: building
order: 1
clientId:
type: String
title: Application (client) ID
hint: The app registration's own GUID, from the same Overview page.
icon: key
order: 2
clientSecret:
type: String
title: Client Secret
hint: A secret value from the app registration's Certificates & secrets page. Note the value, not the secret ID — Entra shows it once.
icon: password
sensitive: true
order: 3
emailClaim:
type: String
title: Email Claim
hint: Which claim carries the email address. Entra fills `email` from the account's Mail attribute, or from the optional claim of that name; a tenant that populates neither has the address in `preferred_username` instead.
icon: envelope
default: email
order: 4
displayNameClaim:
type: String
title: Display Name Claim
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
icon: person
default: name
order: 5
pictureClaim:
type: String
title: Picture Claim
hint: Which claim carries the URL of the account's picture, fetched on login and stored as the avatar. Empty by default because Entra sends no such claim unless the app registration is set up to map one.
icon: image
default: ''
order: 6
mapGroups:
type: Boolean
title: Map Groups
hint: Put the user in the wiki groups the groups claim names, on every login. Only groups that already exist here are matched — nothing is created.
icon: user-groups
default: false
order: 7
groupsClaim:
type: String
title: Groups Claim
hint: Which claim carries the groups. Configure the app registration's token to emit it — note that Entra sends group object IDs unless the tenant is synced from Active Directory and set to emit sAMAccountName, so a wiki group has to be named to match whatever arrives.
icon: rules
default: groups
order: 8
if:
- { key: 'mapGroups', eq: true }
unassignMissingGroups:
type: Boolean
title: Unassign from groups no longer present in claim
hint: Off adds what the claim names and takes nothing away, so a membership granted here survives. On makes Entra the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
icon: unfriend
default: false
order: 9
if:
- { key: 'mapGroups', eq: true }
refs:
callbackUrl:
title: Redirect URI
hint: Add this to the app registration's redirect URIs, as a Web platform.
icon: back
value: '{host}/_api/auth/{id}/callback'

@ -34,15 +34,36 @@ export default class GitHubAuthentication {
: { web: 'https://github.com', api: 'https://api.github.com' }
}
/** A GitHub API call as this user, with the headers GitHub asks every client to send. */
/** The headers GitHub asks every client to send, as this user. */
private apiHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Wiki.js'
}
}
/**
* `fetch`, with an unreachable GitHub reported as a provider failure rather than as itself.
*
* A rejected fetch carries a message about sockets and DNS, and the callback route puts whatever it
* caught into the URL it redirects to so left alone, "fetch failed" is what the person trying to
* log in reads. Every call this module makes goes through here for that reason.
*/
private async reach(url: string, init: RequestInit): Promise<Response> {
try {
return await fetch(url, init)
} catch (err: any) {
WIKI.logger.warn(`GitHub strategy ${this.strategyId} could not reach ${url}: ${err.message}`)
throw new Error('ERR_PROVIDER_REQUEST_FAILED')
}
}
/** A GitHub API call as this user. */
private async api(path: string, accessToken: string): Promise<any> {
const resp = await fetch(`${this.hosts.api}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Wiki.js'
}
const resp = await this.reach(`${this.hosts.api}${path}`, {
headers: this.apiHeaders(accessToken)
})
if (!resp.ok) {
throw new Error(`ERR_PROVIDER_REQUEST_FAILED`)
@ -50,6 +71,41 @@ export default class GitHubAuthentication {
return resp.json()
}
/**
* Whether this account is a member of the organization the strategy requires.
*
* `GET /orgs/{org}/members/{username}` answers from the point of view of whoever is asking, and the
* token asking here belongs to the person signing in so a member checking themselves gets 204. A
* non-member gets a 302 to `/orgs/{org}/public_members/{username}`, which `fetch` follows on its
* own (same origin, so the Authorization header survives it). The consequence worth knowing: the
* question quietly becomes "is a PUBLIC member" whenever the token cannot see private membership
* an organization with OAuth app access restrictions that has not approved this app which is why
* the setting's hint asks for either a public membership or an approved app.
*
* **Only 404 is a refusal.** Every other answer is a failure to find out: a token revoked between
* the exchange and here, an abuse-detection 403, GitHub being down. Reporting those as "you are not
* a member of this organization" sends a legitimate member away with an answer that is wrong,
* unactionable, and indistinguishable in the log from a genuine refusal.
*
* @throws `ERR_PROVIDER_REQUEST_FAILED` when membership could not be determined
*/
private async isOrgMember(org: string, login: string, accessToken: string): Promise<boolean> {
const path = `/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(login)}`
const resp = await this.reach(`${this.hosts.api}${path}`, {
headers: this.apiHeaders(accessToken)
})
if (resp.status === 204) {
return true
}
if (resp.status === 404) {
return false
}
WIKI.logger.warn(
`GitHub strategy ${this.strategyId} could not check membership of ${org} for ${login}: the API answered ${resp.status}.`
)
throw new Error('ERR_PROVIDER_REQUEST_FAILED')
}
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<string> {
if (!this.conf.clientId || !this.conf.clientSecret) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
@ -74,7 +130,7 @@ export default class GitHubAuthentication {
throw new Error('ERR_NO_AUTHORIZATION_CODE')
}
// -> `Accept: application/json`, or GitHub answers this one in form encoding
const tokenResp = await fetch(`${this.hosts.web}/login/oauth/access_token`, {
const tokenResp = await this.reach(`${this.hosts.web}/login/oauth/access_token`, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -88,8 +144,18 @@ export default class GitHubAuthentication {
code
})
})
const token = (await tokenResp.json()) as Record<string, any>
// -> GitHub reports a refused exchange as 200 with an `error` field, not as a status
/*
GitHub reports a refused exchange as 200 with an `error` field rather than as a status, so the
body has to be read either way and a body that is not JSON at all is something else answering
on GitHub's behalf, a proxy or a captive portal, which is a failed exchange and not a parse
error to hand to the person logging in.
*/
let token: Record<string, any>
try {
token = (await tokenResp.json()) as Record<string, any>
} catch {
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
}
if (!tokenResp.ok || token.error || !token.access_token) {
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
}
@ -111,20 +177,8 @@ export default class GitHubAuthentication {
if (this.conf.allowedOrganization) {
const org = this.conf.allowedOrganization.trim()
const resp = await fetch(
`${this.hosts.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(account.login)}`,
{
headers: {
Authorization: `Bearer ${token.access_token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Wiki.js'
}
}
)
// -> 204 is a member, 302 is "ask as somebody who can see", 404 is not a member
if (resp.status !== 204) {
throw new Error('ERR_LOGIN_RESTRICTED')
if (!(await this.isOrgMember(org, account.login, token.access_token))) {
throw new Error('ERR_ACCOUNT_NOT_ALLOWED')
}
}

@ -87,7 +87,7 @@ export default class GoogleAuthentication {
throw new Error('ERR_EMAIL_NOT_VERIFIED')
}
if (this.conf.hostedDomain && claims.hd !== this.conf.hostedDomain) {
throw new Error('ERR_LOGIN_RESTRICTED')
throw new Error('ERR_ACCOUNT_NOT_ALLOWED')
}
return {

@ -0,0 +1,298 @@
import fs from 'node:fs/promises'
import type { ConnectionOptions } from 'node:tls'
import { Client, Filter, InvalidCredentialsError } from 'ldapts'
import type { Entry, SearchOptions } from 'ldapts'
import type { ProviderProfile } from '../../../models/authentication.ts'
/** What a form module is handed for one attempt. `login()` in `models/users.ts` assembles it. */
interface FormCredential {
username: string
password: string
}
/** How long any one directory operation may take before the login is failed. */
const OPERATION_TIMEOUT_MS = 10_000
/**
* LDAP / Active Directory
*
* A form login whose password is checked by the directory rather than here: the wiki searches for the
* entry the username names, then asks the directory to bind as that entry with the password given. A
* bind that succeeds is the proof nothing about the password is ever read, compared or stored on
* this side, which is the whole point of authenticating against a directory.
*
* Because the credential lives elsewhere, this module answers with a `ProviderProfile` instead of a
* user of this wiki. `models/users.ts` matches or creates the account from it, applies the strategy's
* registration rules, and takes the groups and the avatar with it the same path a redirect login
* takes, and the reason `profile()` is the method implemented here rather than `authenticate()`.
*
* Two connections per login, not one. A bind is a property of the connection, so binding as the
* person being authenticated would leave the search connection holding their rights: the group
* lookup that follows is done as the wiki's own read-only account, and the password check gets a
* connection of its own that is thrown away with it.
*/
export default class LdapAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
/**
* The trusted CA, read from disk once.
*
* `null` until it has been looked for, so a directory with no extra certificate configured does not
* go to the filesystem on every login either.
*/
private ca: Buffer[] | null = null
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
/**
* Who signed in, as the directory has them.
*
* @throws `ERR_LOGIN_FAILED` for a username the directory does not have or a password it refuses,
* `ERR_NO_PROVIDER_ACCOUNT` for an entry with no unique ID, `ERR_NO_EMAIL_FROM_PROVIDER`
* for one with no address, `ERR_STRATEGY_MISCONFIGURED` when the strategy cannot be used at
* all, and `ERR_PROVIDER_REQUEST_FAILED` when the directory could not be reached
*/
async profile({ username, password }: FormCredential): Promise<ProviderProfile> {
const { url, bindDn, searchBase, searchFilter } = this.conf
if (!url || !bindDn || !searchBase || !searchFilter) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
if (!searchFilter.includes('{{username}}')) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
/*
An empty password is refused before the directory is asked, because most directories would
answer it with an *unauthenticated* bind a success that proves nothing. It is the oldest way
into an LDAP-backed application and it must never reach the wire.
*/
if (!username || !password) {
throw new Error('ERR_LOGIN_FAILED')
}
const search = await this.connect()
try {
await search.bind(bindDn, this.conf.bindCredentials ?? '')
const found = await search.search(searchBase, {
scope: 'sub',
filter: searchFilter.replaceAll('{{username}}', Filter.escape(username)),
sizeLimit: 2,
...this.attributeOptions()
})
/*
Exactly one entry, or nobody signs in. More than one means the filter does not identify a
person and then binding as "the first" of them would be authenticating whichever entry the
directory happened to return first.
*/
if (found.searchEntries.length !== 1) {
throw new Error('ERR_LOGIN_FAILED')
}
const entry = found.searchEntries[0]
await this.verifyPassword(entry.dn, password)
const id = this.attr(entry, this.conf.mappingUID || 'uid')
if (!id) {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
const email = this.attr(entry, this.conf.mappingEmail || 'mail')
if (!email) {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
return {
id,
email,
name: this.attr(entry, this.conf.mappingDisplayName || 'displayName') || email,
pictureData: this.pictureFrom(entry),
...(this.conf.mapGroups === true
? {
groups: await this.groupsFor(search, entry),
groupsExclusive: this.conf.unassignMissingGroups === true
}
: {})
}
} catch (err: any) {
throw this.asLoginError(err)
} finally {
await this.release(search)
}
}
/**
* A connection to the directory, encrypted as the configuration asks.
*
* `ldaps://` is encrypted from the first byte and takes the TLS options as it connects; StartTLS
* opens in the clear and upgrades before anything is sent, which is a request of its own and so a
* second round trip. Both end up at the same place, and which one a directory offers is not this
* module's business the URL says.
*/
private async connect(): Promise<Client> {
const secure = this.conf.url.toLowerCase().startsWith('ldaps://')
// -> StartTLS on an `ldaps://` URL would be upgrading a connection that is already encrypted
const startTls = this.conf.tlsEnabled === true && !secure
const tlsOptions = secure || startTls ? await this.tlsOptions() : undefined
const conn = new Client({
url: this.conf.url,
timeout: OPERATION_TIMEOUT_MS,
connectTimeout: OPERATION_TIMEOUT_MS,
/*
Only for a URL that is asking for TLS from the outset. `ldapts` reads any non-empty
`tlsOptions` as "connect with TLS" whatever the scheme says, so handing them over on a plain
connection opens one with a ClientHello to a server expecting LDAP which is a hang and then
a parse error, not a helpful failure. StartTLS gets them on the upgrade instead, which is
where they belong: the point of it is that the connection starts in the clear.
*/
...(secure ? { tlsOptions } : {})
})
if (startTls) {
await conn.startTLS(tlsOptions)
}
return conn
}
/**
* How the directory's certificate is treated.
*
* An extra CA is added to the system's own rather than replacing it, so a directory behind an
* internal authority is trusted without a wiki losing every public one and it is only read at
* all when the certificate is being verified, since there is nothing for it to say otherwise.
*/
private async tlsOptions(): Promise<ConnectionOptions> {
const rejectUnauthorized = this.conf.verifyTLSCertificate !== false
if (!rejectUnauthorized || !this.conf.tlsCertPath) {
return { rejectUnauthorized }
}
if (!this.ca) {
this.ca = [await fs.readFile(this.conf.tlsCertPath)]
}
return { rejectUnauthorized, ca: this.ca }
}
/**
* Ask the directory to bind as the entry, with the password that was typed.
*
* On its own connection, closed straight afterwards: this is the only place the password goes, and
* a connection bound as somebody else has no further use here.
*/
private async verifyPassword(dn: string, password: string): Promise<void> {
const asUser = await this.connect()
try {
await asUser.bind(dn, password)
} catch (err: any) {
if (err instanceof InvalidCredentialsError) {
throw new Error('ERR_LOGIN_FAILED')
}
throw err
} finally {
await this.release(asUser)
}
}
/**
* The names of the groups the directory puts this entry in.
*
* Read with the wiki's own read-only account, on the connection the user entry was found with.
* A group search that fails is a failed login rather than a login with no groups: under
* `unassignMissingGroups` the empty answer would be indistinguishable from the directory saying
* this person belongs to nothing, and would take every mapped membership away.
*/
private async groupsFor(search: Client, entry: Entry): Promise<string[]> {
const { groupSearchBase, groupSearchFilter } = this.conf
if (!groupSearchBase || !groupSearchFilter) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const nameField = this.conf.groupNameField || 'name'
const dnProperty = this.conf.groupDnProperty || 'dn'
const dnValue = dnProperty === 'dn' ? entry.dn : this.attr(entry, dnProperty)
if (!dnValue) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const found = await search.search(groupSearchBase, {
scope: (this.conf.groupSearchScope || 'sub') as SearchOptions['scope'],
filter: groupSearchFilter.replaceAll('{{dn}}', Filter.escape(dnValue)),
attributes: [nameField]
})
return found.searchEntries
.map((grp) => this.attr(grp, nameField))
.filter((name): name is string => Boolean(name))
}
/**
* Which attributes to ask for.
*
* All of the user ones, because the four mappings are configurable and a directory holds far more
* than the wiki knows to name plus the picture as a buffer, since asking for `jpegPhoto` as a
* string is asking for an image decoded as UTF-8.
*/
private attributeOptions(): Pick<SearchOptions, 'attributes' | 'explicitBufferAttributes'> {
const picture = this.conf.mappingPicture
return {
attributes: ['*'],
...(picture ? { explicitBufferAttributes: [picture] } : {})
}
}
/**
* One attribute of an entry, as a string.
*
* LDAP attributes are multi-valued, and a directory is free to answer with one value or a list of
* them for the same attribute a person with two addresses in `mail` is ordinary. The first is
* taken, which is the same choice every LDAP-backed application makes.
*/
private attr(entry: Entry, name: string): string | undefined {
const value = entry[name]
const first = Array.isArray(value) ? value[0] : value
if (first === undefined) {
return undefined
}
const text = Buffer.isBuffer(first) ? first.toString('utf8') : first
return text.trim().length > 0 ? text.trim() : undefined
}
/** The photo held in the entry, when the configuration names an attribute holding one. */
private pictureFrom(entry: Entry): Buffer | undefined {
const name = this.conf.mappingPicture
if (!name) {
return undefined
}
const value = entry[name]
const first = Array.isArray(value) ? value[0] : value
return Buffer.isBuffer(first) && first.length > 0 ? first : undefined
}
/**
* Turn whatever the directory or the network raised into a code the login screen can put in front
* of somebody.
*
* An `ERR_` message is already one and is passed through. Anything else is the directory being
* unreachable, misconfigured or unhappy, which is not the person's fault and must not read as a
* wrong password so it is logged as itself and reported as a provider failure.
*/
private asLoginError(err: any): Error {
if (typeof err?.message === 'string' && err.message.startsWith('ERR_')) {
return err
}
// -> The class name as well as the message: `ldapts` raises a result-code error whose message is
// only the code, and "InvalidCredentialsError" is what says the wiki's own bind DN is wrong
WIKI.logger.warn(
`LDAP strategy ${this.strategyId} could not complete a login: ${err.name}: ${err.message}`
)
return new Error('ERR_PROVIDER_REQUEST_FAILED')
}
/** Close a connection without letting the close itself fail a login that already succeeded. */
private async release(conn: Client): Promise<void> {
try {
await conn.unbind()
} catch (err: any) {
WIKI.logger.debug(`LDAP strategy ${this.strategyId} could not unbind cleanly: ${err.message}`)
}
}
}

@ -0,0 +1,159 @@
key: ldap
title: LDAP / Active Directory
description: Lightweight Directory Access Protocol, as spoken by Active Directory, OpenLDAP, FreeIPA and everything else that holds a directory of people.
author: requarks.io
logo: https://static.requarks.io/logo/active-directory.svg
icon: /_assets/icons/ultraviolet-windows8.svg
color: blue-grey-7
isAvailable: true
useForm: true
usernameType: username
props:
url:
type: String
title: LDAP URL
hint: e.g. ldap://directory.example.com:389, or ldaps://directory.example.com:636 for a connection that is encrypted from the start.
icon: internet
default: 'ldap://localhost:389'
order: 1
bindDn:
type: String
title: Admin Bind DN
hint: The distinguished name of the account this wiki searches the directory as. It needs to read the user entries and nothing more.
icon: administrator-male
default: 'cn=readonly,dc=example,dc=com'
order: 2
bindCredentials:
type: String
title: Admin Bind Credentials
hint: The password of the account above.
icon: password
sensitive: true
order: 3
searchBase:
type: String
title: Search Base
hint: The base DN under which to look for the person signing in.
icon: folder
default: 'ou=people,dc=example,dc=com'
order: 4
searchFilter:
type: String
title: Search Filter
hint: How a username is turned into one entry. `{{username}}` must appear and is substituted with what was typed, escaped. e.g. (uid={{username}}) or (sAMAccountName={{username}}).
icon: search
default: '(uid={{username}})'
order: 5
tlsEnabled:
type: Boolean
title: Use StartTLS
hint: Upgrade a plain `ldap://` connection to TLS before anything is sent over it. Leave off for an `ldaps://` URL, which is encrypted already.
icon: security-ssl
default: false
order: 6
verifyTLSCertificate:
type: Boolean
title: Verify TLS Certificate
hint: Check the directory's certificate against the trusted authorities. Turning this off means the connection is encrypted but the server is not identified, which is no protection at all against something sitting in the middle of it.
icon: security-configuration
default: true
order: 7
tlsCertPath:
type: String
title: TLS Certificate Path
hint: (optional) Absolute path, on the server, to the PEM certificate authority to trust in addition to the system's own. For a directory using an internal CA.
icon: fingerprint-scan
order: 8
mappingUID:
type: String
title: Unique ID Field Mapping
hint: The attribute holding the directory's own identifier for the entry. Usually "uid" or "sAMAccountName". It has to be one that is never reassigned.
icon: key
default: 'uid'
order: 20
mappingEmail:
type: String
title: Email Field Mapping
hint: The attribute holding the email address, usually "mail". An account here is matched on it, so an entry without one cannot sign in.
icon: envelope
default: 'mail'
order: 21
mappingDisplayName:
type: String
title: Display Name Field Mapping
hint: The attribute holding the name to show. Usually "displayName" or "cn". Falls back to the email address when the entry has neither.
icon: person
default: 'displayName'
order: 22
mappingPicture:
type: String
title: Avatar Picture Field Mapping
hint: The attribute holding the account's photo, usually "jpegPhoto" or "thumbnailPhoto" — the image itself, not a link to one. Leave empty to let people keep whatever avatar they set here.
icon: image
default: 'jpegPhoto'
order: 23
mapGroups:
type: Boolean
title: Map Groups
hint: Put the user in the wiki groups their directory groups are named after, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
icon: user-groups
default: false
order: 24
groupSearchBase:
type: String
title: Group Search Base
hint: The base DN under which to look for the groups an entry belongs to.
icon: folder
default: 'ou=groups,dc=example,dc=com'
order: 25
if:
- { key: 'mapGroups', eq: true }
groupSearchFilter:
type: String
title: Group Search Filter
hint: Which groups count as the user's. `{{dn}}` is substituted with the value of the property below, escaped. (member={{dn}}) is right for most directories.
icon: search
default: '(member={{dn}})'
order: 26
if:
- { key: 'mapGroups', eq: true }
groupSearchScope:
type: String
title: Group Search Scope
hint: How far below the Group Search Base to look. `sub` searches the whole subtree, `one` its immediate children, `base` only the entry itself.
icon: depth
default: sub
enum:
- base
- one
- sub
order: 27
if:
- { key: 'mapGroups', eq: true }
groupDnProperty:
type: String
title: Group DN Property
hint: Which property of the user's entry `{{dn}}` stands for in the filter above. Usually "dn".
icon: symlink-directory
default: dn
order: 28
if:
- { key: 'mapGroups', eq: true }
groupNameField:
type: String
title: Group Name Field
hint: The attribute on a group entry holding the name to match a wiki group against. Usually "name" or "cn".
icon: rename
default: name
order: 29
if:
- { key: 'mapGroups', eq: true }
unassignMissingGroups:
type: Boolean
title: Unassign from groups no longer present in directory
hint: Off adds what the directory says and takes nothing away, so a membership granted here survives. On makes the directory the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
icon: unfriend
default: false
order: 30
if:
- { key: 'mapGroups', eq: true }

@ -12,6 +12,9 @@ import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../model
* That verification is why this goes through `openid-client` rather than a handful of `fetch` calls.
* The requests themselves are trivial; the checks around them are where a mistake is silent, because
* a token that is never verified still logs somebody in.
*
* Everything past the verification is reading claims, and which claim carries what is configurable
* throughout providers agree on the flow far more than they agree on their vocabulary.
*/
export default class OidcAuthentication {
strategyId: string
@ -64,12 +67,58 @@ export default class OidcAuthentication {
clientSecret
)
}
// -> After either branch: which endpoint the userinfo request goes to is what discovery answers,
// and how the token is presented to it is a separate question with the same answer either way
if (this.conf.useQueryStringForAccessToken === true) {
this.accessTokenInQueryString(this.config)
}
return this.config
}
/** Where to send the browser to sign in. */
/**
* Move the access token out of the Authorization header and onto the userinfo request's query
* string, as `access_token`.
*
* Both are ways OAuth2 defines of presenting a bearer token, and the header is the one to use the
* query string puts a live credential in every access log and referrer between here and the
* provider, which is why the spec discourages it. It is offered because some providers read the
* token from nowhere else, which has nothing to do with whether they publish a discovery document.
*
* The request is rewritten rather than written out again, because reimplementing the call would
* mean reimplementing its subject check and its signed-response handling too, and a userinfo answer
* nobody checked may be about somebody else.
*/
private accessTokenInQueryString(config: client.Configuration): void {
const endpoint = config.serverMetadata().userinfo_endpoint
// -> The library asks for the endpoint's `href`, so the configured string is normalized once here
// rather than compared as it was typed
const userInfoHref = endpoint ? new URL(endpoint).href : null
config[client.customFetch] = (url, options) => {
// -> Only a Bearer presentation is moved: a DPoP-bound token is not a credential the query
// string can carry, and every other request the library makes is somebody else's business
const bearer = /^Bearer (.+)$/.exec(options.headers.authorization ?? '')?.[1]
if (!userInfoHref || !bearer || new URL(url).href !== userInfoHref) {
return fetch(url, options)
}
const target = new URL(url)
target.searchParams.set('access_token', bearer)
const headers = { ...options.headers }
delete headers.authorization
return fetch(target, { ...options, headers })
}
}
/**
* Where to send the browser to sign in.
*
* `acr_values` asks the provider for a kind of authentication rather than telling it to use one:
* OpenID Connect makes the parameter voluntary, and the context actually satisfied comes back as
* the `acr` claim. So it goes on the request here and is checked again in `profile()` the same
* division the Google module makes for a Workspace domain.
*/
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
const config = await this.configuration()
const acr = this.acrValues()
return client
.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
@ -77,7 +126,8 @@ export default class OidcAuthentication {
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256'
code_challenge_method: 'S256',
...(acr.length > 0 ? { acr_values: acr.join(' ') } : {})
})
.toString()
}
@ -105,11 +155,14 @@ export default class OidcAuthentication {
if (!claims?.sub) {
throw new Error('ERR_NO_ID_TOKEN')
}
// -> Before the userinfo round trip: no point spending one on a login already being refused
this.checkAuthContext(claims)
/*
The userinfo endpoint is consulted when the provider has one, because a provider is free to keep
claims out of the ID token and behind it several put the email address there only. Its answer
is merged over the token's, and `fetchUserInfo` checks that it is about the same subject.
claims out of the ID token and behind it several put the email address there only, and group
membership is behind it more often than not. Its answer is merged over the token's, and
`fetchUserInfo` checks that it is about the same subject.
*/
let info: Record<string, any> = claims
if (config.serverMetadata().userinfo_endpoint) {
@ -119,19 +172,135 @@ export default class OidcAuthentication {
}
}
/*
`sub` is the identifier OIDC guarantees is stable and never reassigned, and is what this reads
unless an administrator names another claim. Whichever it is, it is what the account is linked
by from here on the ID token's own subject stays what the library verified the answer against.
*/
const id = info[this.conf.idClaim || 'sub']
if (!id || typeof id !== 'string') {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
const email = info[this.conf.emailClaim || 'email']
if (!email || typeof email !== 'string') {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
return {
id: claims.sub,
id,
email,
name: (info[this.conf.displayNameClaim || 'name'] as string) || email
name: (info[this.conf.displayNameClaim || 'name'] as string) || email,
picture: this.pictureFrom(info),
// -> Absent rather than empty when groups are not mapped: an empty list is the provider saying
// this person is in none, which with `unassignMissingGroups` on takes memberships away
...(this.conf.mapGroups === true
? {
groups: this.groupsFrom(info),
groupsExclusive: this.conf.unassignMissingGroups === true
}
: {})
}
}
/** Where a logout should continue, so that the session at the provider ends too. */
logoutUrl(): string | null {
return this.conf.logoutURL || null
/**
* The authentication contexts this wiki asks for, most preferred first.
*
* Empty unless the setting is on, so turning it off stops asking rather than leaving whatever was
* typed on every request.
*/
private acrValues(): string[] {
if (this.conf.useAcrValues !== true) {
return []
}
return String(this.conf.acrValues || '')
.split(/\s+/)
.filter((one) => one.length > 0)
}
/**
* Hold the provider to the authentication context that was asked for.
*
* Read from the ID TOKEN's claims and not from the merged userinfo answer, deliberately: `acr` is a
* statement about how the person authenticated, and the only thing entitled to make it is the
* document the provider signed. A userinfo response is fetched with an access token and must not be
* able to assert its own authentication context.
*
* Strict about the shape as well as the value. `acr` is a single string in the spec, so anything
* else absent, an array from a provider that has misread it counts as not satisfied: this is the
* check that stands between a wiki and a login weaker than it asked for, and the useful direction
* for it to fail in is closed.
*
* @throws `ERR_ACR_NOT_SATISFIED`, or `ERR_STRATEGY_MISCONFIGURED` when the requirement is turned
* on with nothing named to require
*/
private checkAuthContext(claims: Record<string, any>): void {
if (this.conf.useAcrValues !== true || this.conf.requireAcr !== true) {
return
}
const wanted = this.acrValues()
if (wanted.length < 1) {
// -> Fails closed. "Require the context" with no context named cannot be satisfied or refuted,
// and silently letting everybody through is the one answer that must not be given.
WIKI.logger.warn(
`OIDC strategy ${this.strategyId} requires an authentication context but names none — refusing logins until ACR Values is filled in or the requirement is turned off.`
)
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const satisfied = claims.acr
if (typeof satisfied !== 'string' || !wanted.includes(satisfied)) {
WIKI.logger.warn(
`OIDC strategy ${this.strategyId} refused a login: asked for acr ${wanted.join(' ')}, the provider answered ${JSON.stringify(satisfied) ?? 'nothing'}.`
)
throw new Error('ERR_ACR_NOT_SATISFIED')
}
}
/**
* The URL of the account's picture, if the claim carrying it names one.
*
* An empty claim name turns this off, which is how a wiki lets people keep an avatar they chose
* here rather than having it replaced at every sign-in.
*/
private pictureFrom(info: Record<string, any>): string | undefined {
const claim = this.conf.pictureClaim ?? 'picture'
if (!claim) {
return undefined
}
const value = info[claim]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* The group names the claim carries.
*
* Either one name or a list of them: a provider with a single group per account commonly sends the
* bare string, and both forms mean the same thing here. Anything else in the list a nested object
* from a provider that sends group records rather than names is skipped rather than stringified,
* since a name that matches nothing is a membership silently not granted.
*/
private groupsFrom(info: Record<string, any>): string[] {
const value = info[this.conf.groupsClaim || 'groups']
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
return raw
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim())
}
/**
* Where a logout should continue, so that the session at the provider ends too.
*
* Discovered when discovery is on, which is why the field is only asked for when it is off: the
* `end_session_endpoint` is published in the same document as every other endpoint, and a provider
* that moves it is then followed without an administrator editing anything. Null when the provider
* publishes none plenty do not, and RP-initiated logout is optional in the spec.
*
* Sent as it stands, with no `id_token_hint` or `post_logout_redirect_uri`: neither is required,
* and where a provider asks the person to confirm the sign-out because of it, confirming is not the
* failure mode worth adding stored ID tokens to avoid.
*/
async logoutUrl(): Promise<string | null> {
if (this.conf.useDiscovery === false) {
return this.conf.logoutURL || null
}
return (await this.configuration()).serverMetadata().end_session_endpoint ?? null
}
}

@ -59,12 +59,27 @@ props:
order: 7
if:
- { key: 'useDiscovery', eq: false }
useQueryStringForAccessToken:
type: Boolean
title: Pass access token via GET query string to User Info Endpoint
hint: Pass the access token in an `access_token` parameter attached to the GET query string of the User Info Endpoint URL. Otherwise the access token will be passed in the Authorization header.
icon: download-from-cloud
default: false
order: 8
jwksURL:
type: String
title: JSON Web Key Set URL
hint: Where the keys that signed the ID token are published. Without it the ID token cannot be verified and logins are refused.
icon: fingerprint-scan
order: 8
order: 9
if:
- { key: 'useDiscovery', eq: false }
logoutURL:
type: String
title: Logout URL
hint: Optional - Where the browser is sent once the wiki has logged somebody out, so that the provider's own session ends too — its `end_session_endpoint`. Discovery finds this on its own, which is why it is only asked for here. Without it, signing out leaves the provider still signed in and the next login goes through without a password being asked for.
icon: exit
order: 10
if:
- { key: 'useDiscovery', eq: false }
scopes:
@ -73,27 +88,85 @@ props:
hint: Space-separated. `openid` is required; `email` is what an account is matched on here.
icon: rules
default: 'openid profile email'
order: 9
order: 11
useAcrValues:
type: Boolean
title: Use ACR Values
hint: Optional - Ask the provider for a particular kind of sign-in — two-factor, a smart card, a specific policy — by naming the authentication context this wiki wants.
icon: pin-pad
default: false
order: 12
acrValues:
type: String
title: ACR Values
hint: Space-separated Authentication Context Class References, most preferred first, as the provider documents them. e.g. `urn:mace:incommon:iap:silver`, or a policy name the provider defines.
icon: rules
default: ''
order: 13
if:
- { key: 'useAcrValues', eq: true }
requireAcr:
type: Boolean
title: Require the authentication context
hint: Refuse a login whose `acr` claim is not one of the values above. Off, they are only a request — OpenID Connect lets a provider ignore them and still answer with a valid token, so without this the setting expresses a preference rather than a requirement. Turn it on once the provider is known to return the claim, since one that returns none refuses everybody.
icon: secure
default: false
order: 14
if:
- { key: 'useAcrValues', eq: true }
idClaim:
type: String
title: ID Claim
hint: Which claim carries the provider's own identifier for the account. Usually sub or id, which never changes.
icon: key
default: sub
order: 15
emailClaim:
type: String
title: Email Claim
hint: Which claim carries the email address.
icon: envelope
default: email
order: 10
order: 16
displayNameClaim:
type: String
title: Display Name Claim
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
icon: person
default: name
order: 11
logoutURL:
order: 17
pictureClaim:
type: String
title: Logout URL
hint: (optional) Where to send a user after logging out, so that the session at the provider ends too.
icon: exit
order: 12
title: Picture Claim
hint: Which claim carries the URL of the account's picture, fetched on login and stored as the avatar. Leave empty to let people keep whatever avatar they set here.
icon: image
default: picture
order: 18
mapGroups:
type: Boolean
title: Map Groups
hint: Put the user in the wiki groups a claim names, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
icon: user-groups
default: false
order: 19
groupsClaim:
type: String
title: Groups Claim
hint: Which claim carries the group names. Either one name or a list of them.
icon: rules
default: groups
order: 20
if:
- { key: 'mapGroups', eq: true }
unassignMissingGroups:
type: Boolean
title: Unassign from groups no longer present in claim
hint: Off adds what the claim names and takes nothing away, so a membership granted here survives. On makes the provider the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
icon: unfriend
default: false
order: 21
if:
- { key: 'mapGroups', eq: true }
refs:
callbackUrl:
title: Authorization Callback URL

@ -0,0 +1,242 @@
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
import type { SamlConfig } from '@node-saml/node-saml'
import { CustomError } from '../../../helpers/common.ts'
import type {
AuthFlow,
AuthFlowCallback,
AuthRequestTarget,
ProviderProfile
} from '../../../models/authentication.ts'
/** The longest a provider's signing key list may be, so a pasted mistake cannot become a loop. */
const MAX_CERTS = 10
/**
* SAML 2.0
*
* The Web Browser SSO profile: the wiki sends an AuthnRequest to the identity provider, the provider
* authenticates the person and posts a signed assertion back to the callback. What makes it SAML
* rather than a redirect with a claim on the end is that assertion an XML document signed by the
* provider's key, restricted to an audience and valid only for a few minutes and every one of
* those properties is checked before a word of it is believed.
*
* That checking is why this goes through `@node-saml/node-saml`. XML signature verification is not
* something to write: the document is canonicalized, the signature covers a subset of it named by
* reference, and the ways of getting that wrong signature wrapping, comment splicing, a signature
* over a different element than the one being read are the entire published history of broken SAML
* implementations.
*
* The assertion arrives as a cross-site form POST, which is why the definition declares
* `postCallback` and why the flow this login started travels in a cookie of its own. See
* `api/authentication.ts`.
*/
export default class SamlAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
/**
* The provider as `node-saml` sees it.
*
* Built per request rather than kept, because the ACS URL is derived from the request an
* instance answering on more than one hostname has more than one and unlike a discovery
* document it costs nothing: this is a constructor call over values already in hand.
*/
private saml(callbackUrl: string): SAML {
const { entryPoint, issuer, cert } = this.conf
if (!entryPoint || !issuer || !cert) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const idpCert = String(cert)
.split('|')
.map((one) => one.trim())
.filter((one) => one.length > 0)
.slice(0, MAX_CERTS)
if (idpCert.length < 1) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const options: SamlConfig = {
callbackUrl,
entryPoint,
issuer,
idpCert,
identifierFormat: this.conf.identifierFormat || null,
signatureAlgorithm: this.conf.signatureAlgorithm || 'sha256',
digestAlgorithm: this.conf.digestAlgorithm || 'sha256',
wantAssertionsSigned: this.conf.wantAssertionsSigned !== false,
acceptedClockSkewMs: Number.parseInt(this.conf.acceptedClockSkewMs, 10) || 0,
disableRequestedAuthnContext: this.conf.disableRequestedAuthnContext === true,
authnContext: String(this.conf.authnContext || '')
.split('|')
.map((one) => one.trim())
.filter((one) => one.length > 0),
racComparison: this.conf.racComparison || 'exact',
forceAuthn: this.conf.forceAuthn === true,
passive: this.conf.passive === true,
skipRequestCompression: this.conf.skipRequestCompression === true,
authnRequestBinding: this.conf.authnRequestBinding || 'HTTP-Redirect',
/*
Not validated, and it cannot be here. `InResponseTo` is checked against the request IDs this
process issued, which in a clustered wiki is the wrong set: the instance that answers the
provider's POST is not necessarily the one that sent the request, so an assertion for a
perfectly good login would be refused about half the time. The binding between this browser
and this answer is the flow's `state`, echoed back as `RelayState` and checked by the route
which every strategy here is held to, whatever its protocol.
*/
validateInResponseTo: ValidateInResponseTo.never,
...(this.conf.providerName ? { providerName: this.conf.providerName } : {}),
...(this.conf.audience ? { audience: this.conf.audience } : {}),
...(this.conf.privateKey ? { privateKey: this.conf.privateKey } : {}),
...(this.conf.decryptionPvk ? { decryptionPvk: this.conf.decryptionPvk } : {})
}
return new SAML(options)
}
/**
* Where to send the browser to sign in.
*
* The flow's `state` goes as `RelayState`, which the provider echoes back untouched and the route
* checks SAML's equivalent of the `state` an OAuth2 login carries, and the reason a stray
* assertion posted at the callback is not a login.
*
* Which binding produces which answer: Redirect is a URL with the deflated request on its query
* string, POST is a page holding a form the browser submits to the provider. Both are answers the
* start route knows how to send; see `AuthRequestTarget`.
*/
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<AuthRequestTarget> {
const saml = this.saml(redirectUri)
if (this.conf.authnRequestBinding === 'HTTP-POST') {
return { html: await saml.getAuthorizeFormAsync(state, undefined, {}) }
}
return saml.getAuthorizeUrlAsync(state, undefined, {})
}
/**
* Turn the assertion the provider posted into who signed in.
*
* `validatePostResponseAsync` is what does the checking: the signature against the provider's
* certificate, the audience restriction, the conditions' validity window, and the status the
* provider reported. Everything after it is reading attributes.
*/
async profile({ redirectUri, body }: AuthFlowCallback): Promise<ProviderProfile> {
if (!body?.SAMLResponse) {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
const saml = this.saml(redirectUri)
let profile
try {
profile = (await saml.validatePostResponseAsync(body)).profile
} catch (err: any) {
/*
What the library says about a rejected assertion goes to the log and no further. Its messages
are precise an invalid signature, an audience that does not match, conditions not yet
valid and precise is exactly what must not be handed back: this endpoint is open to whoever
can reach the wiki, and told which check it failed, a forged assertion can be worked on until
it passes.
*/
WIKI.logger.warn(`SAML strategy ${this.strategyId} rejected an assertion: ${err.message}`)
throw new Error('ERR_LOGIN_FAILED')
}
if (!profile) {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
/*
Attributes are read off the profile, where `node-saml` puts each of them under its own name
alongside the NameID and the rest of the assertion's own fields. A configured mapping is
therefore looked up as a plain key, which is what lets it be either a bare attribute name or one
of the URI-shaped ones an AD FS or an Entra assertion uses.
*/
const id = this.attr(profile, this.conf.mappingUID) ?? profile.nameID
if (!id) {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
const email = this.attr(profile, this.conf.mappingEmail)
if (!email) {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
return {
id,
email,
name: this.attr(profile, this.conf.mappingDisplayName) || email,
picture: this.attr(profile, this.conf.mappingPicture),
...(this.conf.mapGroups === true
? {
groups: this.groupsFrom(profile),
groupsExclusive: this.conf.unassignMissingGroups === true
}
: {})
}
}
/**
* This wiki as a service provider, in the form a provider configures itself from.
*
* Carries the entity ID, the ACS URL and the certificates the public halves, and only where the
* corresponding key is configured, since a provider has nothing to do with a certificate this wiki
* never signs or decrypts with. Served by `GET /_api/auth/:strategyId/metadata`.
*/
async metadata({ callbackUrl }: { callbackUrl: string }): Promise<string> {
/*
A key with no certificate beside it cannot be described. Refused with a message rather than
left to fail inside the library, since the answer is a specific thing to go and do and a 500
on a public endpoint says nothing about which of the two fields is missing.
*/
if (this.conf.privateKey && !this.conf.signingCert) {
throw new CustomError(
'samlMetadataIncomplete',
'This strategy signs its requests, so its Signing Certificate has to be configured before metadata can describe it.'
)
}
if (this.conf.decryptionPvk && !this.conf.decryptionCert) {
throw new CustomError(
'samlMetadataIncomplete',
'This strategy accepts encrypted assertions, so its Decryption Certificate has to be configured before metadata can describe it.'
)
}
return this.saml(callbackUrl).generateServiceProviderMetadata(
this.conf.decryptionCert || null,
this.conf.signingCert || null
)
}
/**
* One attribute of the assertion, as a string.
*
* A SAML attribute may carry several values, and `node-saml` hands over an array when it does. The
* first is taken. An empty mapping means the administrator has turned that mapping off, which is
* not the same as an attribute that happens to be missing.
*/
private attr(profile: Record<string, any>, name: string | undefined): string | undefined {
if (!name) {
return undefined
}
const value = profile[name]
const first = Array.isArray(value) ? value[0] : value
if (typeof first !== 'string') {
return undefined
}
return first.trim().length > 0 ? first.trim() : undefined
}
/**
* The group names the assertion carries.
*
* Either one name or a list of them: a provider sending a single group commonly sends the bare
* string, and both forms mean the same thing here.
*/
private groupsFrom(profile: Record<string, any>): string[] {
const value = profile[this.conf.mappingGroups || 'memberOf']
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
return raw
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim())
}
}

@ -0,0 +1,242 @@
key: saml
title: SAML 2.0
description: Security Assertion Markup Language 2.0, the standard for exchanging authentication and authorization data between security domains.
author: requarks.io
logo: https://static.requarks.io/logo/saml.svg
icon: /_assets/icons/ultraviolet-saml.svg
color: red-7
isAvailable: true
useForm: false
usernameType: email
postCallback: true
props:
entryPoint:
type: String
title: Entry Point
hint: The identity provider's single sign-on URL, where the browser is sent to log in.
icon: enter
order: 1
issuer:
type: String
title: Issuer
hint: The entity ID this wiki identifies itself to the provider as. Any stable string the provider is told to expect — a URL naming this wiki is the convention.
icon: address
order: 2
audience:
type: String
title: Audience
hint: (optional) The audience an assertion must be restricted to for this wiki to accept it. Defaults to the Issuer above, which is what a provider configured against this wiki will send.
icon: team
order: 3
cert:
type: String
title: Certificate
hint: The provider's public PEM-encoded X.509 signing certificate, which is what every assertion is checked against. Join several with the | pipe symbol where the provider is rotating keys.
icon: security-ssl
multiline: true
order: 4
privateKey:
type: String
title: Private Key
hint: (optional) PEM-formatted key this wiki signs its authentication requests with. Only needed by a provider that requires signed requests.
icon: key
multiline: true
sensitive: true
order: 5
signingCert:
type: String
title: Signing Certificate
hint: The public PEM-encoded X.509 certificate matching the private key above. Required alongside it, because it is what the metadata document publishes for the provider to verify this wiki's requests with.
icon: validation
multiline: true
order: 6
decryptionPvk:
type: String
title: Decryption Private Key
hint: (optional) PEM-formatted key used to decrypt encrypted assertions. Only needed by a provider that encrypts them.
icon: password
multiline: true
sensitive: true
order: 7
decryptionCert:
type: String
title: Decryption Certificate
hint: The public PEM-encoded X.509 certificate matching the decryption key above. Required alongside it, because it is what the metadata document publishes for the provider to encrypt assertions to.
icon: security-configuration
multiline: true
order: 8
signatureAlgorithm:
type: String
title: Signature Algorithm
hint: Which algorithm this wiki signs its requests with. SHA-1 is broken and is here only for a provider that accepts nothing else.
icon: validation
default: sha256
enum:
- sha256|SHA-256
- sha512|SHA-512
- sha1|SHA-1 (insecure)
order: 9
digestAlgorithm:
type: String
title: Digest Algorithm
hint: Which algorithm digests the data being signed. Match it to the signature algorithm unless the provider asks otherwise.
icon: sigma
default: sha256
enum:
- sha256|SHA-256
- sha512|SHA-512
- sha1|SHA-1 (insecure)
order: 10
identifierFormat:
type: String
title: Name Identifier Format
hint: What kind of name the request asks the provider to identify people by. Leave empty to ask for no particular format, which is what a provider that objects to being asked wants.
icon: rename
default: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
order: 20
wantAssertionsSigned:
type: Boolean
title: Require Signed Assertions
hint: Refuse a response whose assertion is not signed in its own right. Worth leaving on — a signature over the response alone leaves the assertion inside it unprotected.
icon: secure
default: true
order: 21
acceptedClockSkewMs:
type: Number
title: Accepted Clock Skew (ms)
hint: How far this server's clock may differ from the provider's before an assertion is judged not yet valid or expired. Set to -1 to stop checking those timestamps entirely, which throws away the assertion's own expiry.
icon: timer
default: 0
order: 22
disableRequestedAuthnContext:
type: Boolean
title: Disable Requested Auth Context
hint: Ask for no particular authentication method, rather than the one below. Known to be what AD FS wants.
icon: rules
default: false
order: 23
authnContext:
type: String
title: Auth Context
hint: Which authentication method the request asks for. Join several with the | pipe symbol.
icon: rules
default: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
order: 24
if:
- { key: 'disableRequestedAuthnContext', eq: false }
racComparison:
type: String
title: RAC Comparison Type
hint: How the provider is to compare what it actually did against the context asked for.
icon: matches
default: exact
enum:
- exact
- minimum
- maximum
- better
order: 25
if:
- { key: 'disableRequestedAuthnContext', eq: false }
forceAuthn:
type: Boolean
title: Force Initial Re-authentication
hint: Ask the provider to authenticate the person again even if they already have a session there.
icon: renew
default: false
order: 26
passive:
type: Boolean
title: Passive
hint: Ask the provider not to interact with the person at all — so an existing session signs them in and no session sends them straight back.
icon: do-not-touch
default: false
order: 27
providerName:
type: String
title: Provider Name
hint: (optional) A human-readable name for this wiki, which a provider may show to the person being asked to log in.
icon: website
default: Wiki.js
order: 28
authnRequestBinding:
type: String
title: Request Binding
hint: How the authentication request reaches the provider. Redirect sends the browser straight there; POST answers with a page holding a form that submits itself, which under a content security policy forbidding inline scripts becomes a button the person has to press.
icon: share
default: 'HTTP-Redirect'
enum:
- HTTP-Redirect|Redirect
- HTTP-POST|POST
order: 29
skipRequestCompression:
type: Boolean
title: Skip Request Compression
hint: Send the authentication request uncompressed. The Redirect binding requires it to be deflated, so this is for a provider that wants otherwise.
icon: downloads
default: false
order: 30
mappingUID:
type: String
title: Unique ID Field Mapping
hint: The attribute holding the provider's own identifier for the account. Falls back to the assertion's NameID, which is what most providers identify people by.
icon: key
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
order: 40
mappingEmail:
type: String
title: Email Field Mapping
hint: The attribute holding the email address. An account here is matched on it, so an assertion without one cannot sign anybody in.
icon: envelope
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
order: 41
mappingDisplayName:
type: String
title: Display Name Field Mapping
hint: The attribute holding the name to show. Falls back to the email address when the assertion has neither.
icon: person
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
order: 42
mappingPicture:
type: String
title: Avatar Picture Field Mapping
hint: The attribute holding the URL of the account's picture, fetched on login and stored as the avatar. Leave empty to let people keep whatever avatar they set here.
icon: image
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/picture'
order: 43
mapGroups:
type: Boolean
title: Map Groups
hint: Put the user in the wiki groups the attribute below names, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
icon: user-groups
default: false
order: 44
mappingGroups:
type: String
title: User Groups Field Mapping
hint: The attribute holding the groups. Either one name or a list of them.
icon: rules
default: 'memberOf'
order: 45
if:
- { key: 'mapGroups', eq: true }
unassignMissingGroups:
type: Boolean
title: Unassign from groups no longer present in assertion
hint: Off adds what the assertion names and takes nothing away, so a membership granted here survives. On makes the provider the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
icon: unfriend
default: false
order: 46
if:
- { key: 'mapGroups', eq: true }
refs:
callbackUrl:
title: Assertion Consumer Service URL
hint: Register this with the provider as where to post assertions. Also called the Reply URL or the ACS URL, depending on whose interface you are in.
icon: back
value: '{host}/_api/auth/{id}/callback'
metadataUrl:
title: Service Provider Metadata
hint: Hand this to a provider that configures itself from a metadata document rather than from pasted values.
icon: rescan-document
value: '{host}/_api/auth/{id}/metadata'

@ -27,6 +27,7 @@
"@google-cloud/storage": "8.0.1",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@node-saml/node-saml": "5.1.0",
"@prometheus-io/client": "0.16.1",
"@simplewebauthn/server": "13.3.2",
"ajv-formats": "3.0.1",
@ -43,6 +44,7 @@
"filesize": "11.0.22",
"fs-extra": "11.4.0",
"js-yaml": "5.2.3",
"ldapts": "9.0.0",
"lib0": "0.2.117",
"mime": "4.1.0",
"nanoid": "6.0.1",
@ -2462,6 +2464,29 @@
],
"license": "MIT"
},
"node_modules/@node-saml/node-saml": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz",
"integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==",
"license": "MIT",
"dependencies": {
"@types/debug": "^4.1.12",
"@types/qs": "^6.9.18",
"@types/xml-encryption": "^1.2.4",
"@types/xml2js": "^0.4.14",
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"debug": "^4.4.0",
"xml-crypto": "^6.1.2",
"xml-encryption": "^3.1.0",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1",
"xpath": "^0.0.34"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@ -3467,6 +3492,15 @@
"node": ">=18.0.0"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/fs-extra": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
@ -3495,11 +3529,16 @@
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@ -3537,6 +3576,12 @@
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
@ -3601,6 +3646,24 @@
"@types/node": "*"
}
},
"node_modules/@types/xml-encryption": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz",
"integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@ -3955,6 +4018,24 @@
"node": ">=22.0.0"
}
},
"node_modules/@xmldom/is-dom-node": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz",
"integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==",
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -6083,6 +6164,18 @@
"dayjs": "^1.11.7"
}
},
"node_modules/ldapts": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-9.0.0.tgz",
"integrity": "sha512-OaaoYBSuan7g0Nm2e1wsRl+9xol41zY+pDlRRSsBq36iKCd2tG/K8WifevNRDjyEfhz4bvkxKdDvJ6xHXwj7+Q==",
"license": "MIT",
"dependencies": {
"strict-event-emitter-types": "2.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
@ -7414,6 +7507,15 @@
"node": ">=20.19.0"
}
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@ -7625,6 +7727,12 @@
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -7906,7 +8014,6 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/universalify": {
@ -8067,6 +8174,49 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml-crypto": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz",
"integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==",
"license": "MIT",
"dependencies": {
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.33"
},
"engines": {
"node": ">=16"
}
},
"node_modules/xml-crypto/node_modules/xpath": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
"integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml-encryption": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
"integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
}
},
"node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
@ -8082,6 +8232,46 @@
"node": ">=16.0.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

@ -53,6 +53,7 @@
"@google-cloud/storage": "8.0.1",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@node-saml/node-saml": "5.1.0",
"@prometheus-io/client": "0.16.1",
"@simplewebauthn/server": "13.3.2",
"ajv-formats": "3.0.1",
@ -69,6 +70,7 @@
"filesize": "11.0.22",
"fs-extra": "11.4.0",
"js-yaml": "5.2.3",
"ldapts": "9.0.0",
"lib0": "0.2.117",
"mime": "4.1.0",
"nanoid": "6.0.1",

@ -37,6 +37,14 @@ declare module 'fastify' {
permissions?: string[]
/** Ids of the groups the user belongs to, which is what per-group visibility is checked against. */
groups?: string[]
/**
* The strategy this session signed in with.
*
* Read on the way out: a provider that wants the browser sent somewhere to end its own session
* says so through the module, and only the module knows where. See the logout route in
* `api/authentication.ts`.
*/
strategyId?: string
/**
* Ids of the password-protected pages this session has entered the password for. Written by the
* unlock route in `api/pages.ts`, and the only thing that opens one for a reader who may not edit

Loading…
Cancel
Save