From 0c8108d5806ddc3d7a41f53e31580e7c3695c45c Mon Sep 17 00:00:00 2001 From: NGPixel Date: Tue, 8 Sep 2026 00:45:09 -0400 Subject: [PATCH] feat: add entra,ldap,saml + map groups + various auth fixes --- backend/api/authentication.ts | 398 +++++++++++++++--- backend/api/groups.ts | 14 +- backend/locales/en.json | 2 + backend/models/authentication.ts | 46 ++ backend/models/groups.ts | 45 +- backend/models/users.ts | 205 ++++++++- .../authentication/entra/authentication.ts | 156 +++++++ .../authentication/entra/definition.yml | 82 ++++ .../authentication/github/authentication.ts | 104 +++-- .../authentication/google/authentication.ts | 2 +- .../authentication/ldap/authentication.ts | 298 +++++++++++++ .../authentication/ldap/definition.yml | 159 +++++++ .../authentication/oidc/authentication.ts | 187 +++++++- .../authentication/oidc/definition.yml | 91 +++- .../authentication/saml/authentication.ts | 242 +++++++++++ .../authentication/saml/definition.yml | 242 +++++++++++ backend/package-lock.json | 194 ++++++++- backend/package.json | 2 + backend/types/fastify.d.ts | 8 + 19 files changed, 2349 insertions(+), 128 deletions(-) create mode 100644 backend/modules/authentication/entra/authentication.ts create mode 100644 backend/modules/authentication/entra/definition.yml create mode 100644 backend/modules/authentication/ldap/authentication.ts create mode 100644 backend/modules/authentication/ldap/definition.yml create mode 100644 backend/modules/authentication/saml/authentication.ts create mode 100644 backend/modules/authentication/saml/definition.yml diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index 3420db82c..3e938680c 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -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 + body?: Record +} + +/** + * 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 { + 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 { + 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((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 + }) + ) - 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 + }) + ) - 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).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 diff --git a/backend/api/groups.ts b/backend/api/groups.ts index a0ba3d035..e77d99086 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -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. diff --git a/backend/locales/en.json b/backend/locales/en.json index 160cb7e40..9b6cc5851 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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.", diff --git a/backend/models/authentication.ts b/backend/models/authentication.ts index 1a2e28660..054ea7e06 100644 --- a/backend/models/authentication.ts +++ b/backend/models/authentication.ts @@ -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 refs?: Record @@ -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 } /** @@ -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. */ diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 72d9eb489..d90f213da 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -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 { + 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 { 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 { 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 diff --git a/backend/models/users.ts b/backend/models/users.ts index a3421ccfb..aa8edd2d4 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -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 { + 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) { + 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 { + 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 { + 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 - 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, diff --git a/backend/modules/authentication/entra/authentication.ts b/backend/modules/authentication/entra/authentication.ts new file mode 100644 index 000000000..0dc0b1984 --- /dev/null +++ b/backend/modules/authentication/entra/authentication.ts @@ -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 + /** 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) { + this.strategyId = strategyId + this.conf = conf + } + + private async configuration(): Promise { + 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 { + 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 { + 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 = 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 | 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[] { + 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()) + } +} diff --git a/backend/modules/authentication/entra/definition.yml b/backend/modules/authentication/entra/definition.yml new file mode 100644 index 000000000..63e36d119 --- /dev/null +++ b/backend/modules/authentication/entra/definition.yml @@ -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' diff --git a/backend/modules/authentication/github/authentication.ts b/backend/modules/authentication/github/authentication.ts index fed2f1219..fffdc3222 100644 --- a/backend/modules/authentication/github/authentication.ts +++ b/backend/modules/authentication/github/authentication.ts @@ -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 { + 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 { + 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 { - 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 { + 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 { 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 - // -> 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 + try { + token = (await tokenResp.json()) as Record + } 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') } } diff --git a/backend/modules/authentication/google/authentication.ts b/backend/modules/authentication/google/authentication.ts index 2cd892c64..4dc925b63 100644 --- a/backend/modules/authentication/google/authentication.ts +++ b/backend/modules/authentication/google/authentication.ts @@ -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 { diff --git a/backend/modules/authentication/ldap/authentication.ts b/backend/modules/authentication/ldap/authentication.ts new file mode 100644 index 000000000..6d3faa13d --- /dev/null +++ b/backend/modules/authentication/ldap/authentication.ts @@ -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 + /** 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await conn.unbind() + } catch (err: any) { + WIKI.logger.debug(`LDAP strategy ${this.strategyId} could not unbind cleanly: ${err.message}`) + } + } +} diff --git a/backend/modules/authentication/ldap/definition.yml b/backend/modules/authentication/ldap/definition.yml new file mode 100644 index 000000000..1b1aa2028 --- /dev/null +++ b/backend/modules/authentication/ldap/definition.yml @@ -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 } diff --git a/backend/modules/authentication/oidc/authentication.ts b/backend/modules/authentication/oidc/authentication.ts index 66083de94..b8916f7f4 100644 --- a/backend/modules/authentication/oidc/authentication.ts +++ b/backend/modules/authentication/oidc/authentication.ts @@ -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 { 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 = 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): 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 | 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[] { + 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 { + if (this.conf.useDiscovery === false) { + return this.conf.logoutURL || null + } + return (await this.configuration()).serverMetadata().end_session_endpoint ?? null } } diff --git a/backend/modules/authentication/oidc/definition.yml b/backend/modules/authentication/oidc/definition.yml index ad7fd0ff5..0d9b23b15 100644 --- a/backend/modules/authentication/oidc/definition.yml +++ b/backend/modules/authentication/oidc/definition.yml @@ -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 diff --git a/backend/modules/authentication/saml/authentication.ts b/backend/modules/authentication/saml/authentication.ts new file mode 100644 index 000000000..9450a9ea7 --- /dev/null +++ b/backend/modules/authentication/saml/authentication.ts @@ -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 + /** Set by `models/authentication.ts` right after construction. */ + module?: string + + constructor(strategyId: string, conf: Record) { + 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 { + 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 { + 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 { + /* + 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, 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[] { + 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()) + } +} diff --git a/backend/modules/authentication/saml/definition.yml b/backend/modules/authentication/saml/definition.yml new file mode 100644 index 000000000..685bfd8b7 --- /dev/null +++ b/backend/modules/authentication/saml/definition.yml @@ -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' diff --git a/backend/package-lock.json b/backend/package-lock.json index cd5d8043d..5a8d4cf95 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 50c617746..25653a784 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/types/fastify.d.ts b/backend/types/fastify.d.ts index 17fec26ef..71bdc0539 100644 --- a/backend/types/fastify.d.ts +++ b/backend/types/fastify.d.ts @@ -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