mirror of https://github.com/requarks/wiki
parent
7ea92a982b
commit
0c8108d580
@ -0,0 +1,156 @@
|
|||||||
|
import * as client from 'openid-client'
|
||||||
|
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/** Where a tenant's OpenID Connect metadata lives. `{tenant}` is the one thing configurable about it. */
|
||||||
|
const ISSUER_TEMPLATE = 'https://login.microsoftonline.com/{tenant}/v2.0'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tenant placeholders Entra accepts and this module does not.
|
||||||
|
*
|
||||||
|
* They are what makes an app registration multi-tenant, and a multi-tenant login is a different
|
||||||
|
* thing from the one being offered here: the ID token would then be accepted from ANY Entra
|
||||||
|
* directory, so anybody with a Microsoft account anywhere could present a valid token for this
|
||||||
|
* wiki. Restricting that means checking the issuer against a list of tenants the wiki accepts, which
|
||||||
|
* is a feature and not a default. Discovery would not carry it off either — the metadata for these
|
||||||
|
* answers with a literal `{tenantid}` in the `issuer` field, which no token ever matches.
|
||||||
|
*/
|
||||||
|
const MULTI_TENANT = ['common', 'organizations', 'consumers']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microsoft Entra ID (formerly Azure Active Directory)
|
||||||
|
*
|
||||||
|
* Entra is an OpenID Connect provider, so this is the generic flow with the issuer built from the
|
||||||
|
* tenant. What is worth saying about Entra specifically is what its tokens carry, because that is
|
||||||
|
* where a working configuration is usually lost:
|
||||||
|
*
|
||||||
|
* - the email address is in `email` only if the account has a Mail attribute or the tenant maps the
|
||||||
|
* optional claim, and is in `preferred_username` otherwise, so which claim to read is a setting;
|
||||||
|
* - the groups claim carries object IDs rather than names unless the tenant is synced from Active
|
||||||
|
* Directory, which is a thing about the directory and not about this module;
|
||||||
|
* - there is no picture claim at all, so an avatar arrives only from a tenant that maps one.
|
||||||
|
*
|
||||||
|
* Written against `openid-client` for the reason the generic module is: the ID token has to be
|
||||||
|
* verified against the tenant's published keys, and a token nobody verified still logs somebody in.
|
||||||
|
*/
|
||||||
|
export default class EntraAuthentication {
|
||||||
|
strategyId: string
|
||||||
|
conf: Record<string, any>
|
||||||
|
/** Set by `models/authentication.ts` right after construction. */
|
||||||
|
module?: string
|
||||||
|
|
||||||
|
/** The tenant as `openid-client` sees it. One discovery round trip, kept for every login after. */
|
||||||
|
private config: client.Configuration | null = null
|
||||||
|
|
||||||
|
constructor(strategyId: string, conf: Record<string, any>) {
|
||||||
|
this.strategyId = strategyId
|
||||||
|
this.conf = conf
|
||||||
|
}
|
||||||
|
|
||||||
|
private async configuration(): Promise<client.Configuration> {
|
||||||
|
if (this.config) {
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
const { tenantId, clientId, clientSecret } = this.conf
|
||||||
|
if (!tenantId || !clientId || !clientSecret) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
if (MULTI_TENANT.includes(String(tenantId).toLowerCase())) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
this.config = await client.discovery(
|
||||||
|
new URL(ISSUER_TEMPLATE.replace('{tenant}', encodeURIComponent(tenantId))),
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
)
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
|
||||||
|
const config = await this.configuration()
|
||||||
|
return client
|
||||||
|
.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async profile({
|
||||||
|
currentUrl,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
codeVerifier
|
||||||
|
}: AuthFlowCallback): Promise<ProviderProfile> {
|
||||||
|
const config = await this.configuration()
|
||||||
|
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
||||||
|
expectedState: state,
|
||||||
|
expectedNonce: nonce,
|
||||||
|
pkceCodeVerifier: codeVerifier
|
||||||
|
})
|
||||||
|
const claims = tokens.claims()
|
||||||
|
if (!claims?.sub) {
|
||||||
|
throw new Error('ERR_NO_ID_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The userinfo endpoint is asked as well as the token read, because a tenant that emits the group
|
||||||
|
claim only "as a distributed claim" — which is what a token past 200 groups gets — keeps it
|
||||||
|
behind there. `fetchUserInfo` checks the answer is about the same subject.
|
||||||
|
*/
|
||||||
|
let info: Record<string, any> = claims
|
||||||
|
if (config.serverMetadata().userinfo_endpoint) {
|
||||||
|
info = {
|
||||||
|
...claims,
|
||||||
|
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = info[this.conf.emailClaim || 'email']
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// -> `oid` is the account's identifier within the tenant and `sub` is its identifier for this
|
||||||
|
// one application. `sub` is the one to link by: it is what the ID token was verified as
|
||||||
|
// being about, and it is stable for as long as the app registration is
|
||||||
|
id: claims.sub,
|
||||||
|
email,
|
||||||
|
name: (info[this.conf.displayNameClaim || 'name'] as string) || email,
|
||||||
|
picture: this.pictureFrom(info),
|
||||||
|
...(this.conf.mapGroups === true
|
||||||
|
? {
|
||||||
|
groups: this.groupsFrom(info),
|
||||||
|
groupsExclusive: this.conf.unassignMissingGroups === true
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the account's picture, for a tenant that maps a claim carrying one.
|
||||||
|
*
|
||||||
|
* Empty by default, and an empty claim name turns it off — Entra emits nothing of the sort on its
|
||||||
|
* own, and a person's photo in Entra is behind Microsoft Graph rather than in a token.
|
||||||
|
*/
|
||||||
|
private pictureFrom(info: Record<string, any>): string | undefined {
|
||||||
|
const claim = this.conf.pictureClaim
|
||||||
|
if (!claim) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const value = info[claim]
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group names — or, as Entra usually has it, the group object IDs — the claim carries. */
|
||||||
|
private groupsFrom(info: Record<string, any>): string[] {
|
||||||
|
const value = info[this.conf.groupsClaim || 'groups']
|
||||||
|
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
|
||||||
|
return raw
|
||||||
|
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
key: entra
|
||||||
|
title: Microsoft Entra ID
|
||||||
|
description: Microsoft Entra ID (formerly Azure Active Directory) is Microsoft's cloud-based identity and access management service.
|
||||||
|
author: requarks.io
|
||||||
|
logo: https://static.requarks.io/logo/azure.svg
|
||||||
|
icon: /_assets/icons/ultraviolet-azure.svg
|
||||||
|
color: blue-7
|
||||||
|
isAvailable: true
|
||||||
|
useForm: false
|
||||||
|
usernameType: email
|
||||||
|
props:
|
||||||
|
tenantId:
|
||||||
|
type: String
|
||||||
|
title: Directory (tenant) ID
|
||||||
|
hint: The tenant this wiki signs people in from — its GUID, or one of its verified domains. From the app registration's Overview page.
|
||||||
|
icon: building
|
||||||
|
order: 1
|
||||||
|
clientId:
|
||||||
|
type: String
|
||||||
|
title: Application (client) ID
|
||||||
|
hint: The app registration's own GUID, from the same Overview page.
|
||||||
|
icon: key
|
||||||
|
order: 2
|
||||||
|
clientSecret:
|
||||||
|
type: String
|
||||||
|
title: Client Secret
|
||||||
|
hint: A secret value from the app registration's Certificates & secrets page. Note the value, not the secret ID — Entra shows it once.
|
||||||
|
icon: password
|
||||||
|
sensitive: true
|
||||||
|
order: 3
|
||||||
|
emailClaim:
|
||||||
|
type: String
|
||||||
|
title: Email Claim
|
||||||
|
hint: Which claim carries the email address. Entra fills `email` from the account's Mail attribute, or from the optional claim of that name; a tenant that populates neither has the address in `preferred_username` instead.
|
||||||
|
icon: envelope
|
||||||
|
default: email
|
||||||
|
order: 4
|
||||||
|
displayNameClaim:
|
||||||
|
type: String
|
||||||
|
title: Display Name Claim
|
||||||
|
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
|
||||||
|
icon: person
|
||||||
|
default: name
|
||||||
|
order: 5
|
||||||
|
pictureClaim:
|
||||||
|
type: String
|
||||||
|
title: Picture Claim
|
||||||
|
hint: Which claim carries the URL of the account's picture, fetched on login and stored as the avatar. Empty by default because Entra sends no such claim unless the app registration is set up to map one.
|
||||||
|
icon: image
|
||||||
|
default: ''
|
||||||
|
order: 6
|
||||||
|
mapGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Map Groups
|
||||||
|
hint: Put the user in the wiki groups the groups claim names, on every login. Only groups that already exist here are matched — nothing is created.
|
||||||
|
icon: user-groups
|
||||||
|
default: false
|
||||||
|
order: 7
|
||||||
|
groupsClaim:
|
||||||
|
type: String
|
||||||
|
title: Groups Claim
|
||||||
|
hint: Which claim carries the groups. Configure the app registration's token to emit it — note that Entra sends group object IDs unless the tenant is synced from Active Directory and set to emit sAMAccountName, so a wiki group has to be named to match whatever arrives.
|
||||||
|
icon: rules
|
||||||
|
default: groups
|
||||||
|
order: 8
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
unassignMissingGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Unassign from groups no longer present in claim
|
||||||
|
hint: Off adds what the claim names and takes nothing away, so a membership granted here survives. On makes Entra the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
|
||||||
|
icon: unfriend
|
||||||
|
default: false
|
||||||
|
order: 9
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
refs:
|
||||||
|
callbackUrl:
|
||||||
|
title: Redirect URI
|
||||||
|
hint: Add this to the app registration's redirect URIs, as a Web platform.
|
||||||
|
icon: back
|
||||||
|
value: '{host}/_api/auth/{id}/callback'
|
||||||
@ -0,0 +1,298 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import type { ConnectionOptions } from 'node:tls'
|
||||||
|
import { Client, Filter, InvalidCredentialsError } from 'ldapts'
|
||||||
|
import type { Entry, SearchOptions } from 'ldapts'
|
||||||
|
import type { ProviderProfile } from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/** What a form module is handed for one attempt. `login()` in `models/users.ts` assembles it. */
|
||||||
|
interface FormCredential {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long any one directory operation may take before the login is failed. */
|
||||||
|
const OPERATION_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LDAP / Active Directory
|
||||||
|
*
|
||||||
|
* A form login whose password is checked by the directory rather than here: the wiki searches for the
|
||||||
|
* entry the username names, then asks the directory to bind as that entry with the password given. A
|
||||||
|
* bind that succeeds is the proof — nothing about the password is ever read, compared or stored on
|
||||||
|
* this side, which is the whole point of authenticating against a directory.
|
||||||
|
*
|
||||||
|
* Because the credential lives elsewhere, this module answers with a `ProviderProfile` instead of a
|
||||||
|
* user of this wiki. `models/users.ts` matches or creates the account from it, applies the strategy's
|
||||||
|
* registration rules, and takes the groups and the avatar with it — the same path a redirect login
|
||||||
|
* takes, and the reason `profile()` is the method implemented here rather than `authenticate()`.
|
||||||
|
*
|
||||||
|
* Two connections per login, not one. A bind is a property of the connection, so binding as the
|
||||||
|
* person being authenticated would leave the search connection holding their rights: the group
|
||||||
|
* lookup that follows is done as the wiki's own read-only account, and the password check gets a
|
||||||
|
* connection of its own that is thrown away with it.
|
||||||
|
*/
|
||||||
|
export default class LdapAuthentication {
|
||||||
|
strategyId: string
|
||||||
|
conf: Record<string, any>
|
||||||
|
/** Set by `models/authentication.ts` right after construction. */
|
||||||
|
module?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted CA, read from disk once.
|
||||||
|
*
|
||||||
|
* `null` until it has been looked for, so a directory with no extra certificate configured does not
|
||||||
|
* go to the filesystem on every login either.
|
||||||
|
*/
|
||||||
|
private ca: Buffer[] | null = null
|
||||||
|
|
||||||
|
constructor(strategyId: string, conf: Record<string, any>) {
|
||||||
|
this.strategyId = strategyId
|
||||||
|
this.conf = conf
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who signed in, as the directory has them.
|
||||||
|
*
|
||||||
|
* @throws `ERR_LOGIN_FAILED` for a username the directory does not have or a password it refuses,
|
||||||
|
* `ERR_NO_PROVIDER_ACCOUNT` for an entry with no unique ID, `ERR_NO_EMAIL_FROM_PROVIDER`
|
||||||
|
* for one with no address, `ERR_STRATEGY_MISCONFIGURED` when the strategy cannot be used at
|
||||||
|
* all, and `ERR_PROVIDER_REQUEST_FAILED` when the directory could not be reached
|
||||||
|
*/
|
||||||
|
async profile({ username, password }: FormCredential): Promise<ProviderProfile> {
|
||||||
|
const { url, bindDn, searchBase, searchFilter } = this.conf
|
||||||
|
if (!url || !bindDn || !searchBase || !searchFilter) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
if (!searchFilter.includes('{{username}}')) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
An empty password is refused before the directory is asked, because most directories would
|
||||||
|
answer it with an *unauthenticated* bind — a success that proves nothing. It is the oldest way
|
||||||
|
into an LDAP-backed application and it must never reach the wire.
|
||||||
|
*/
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error('ERR_LOGIN_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = await this.connect()
|
||||||
|
try {
|
||||||
|
await search.bind(bindDn, this.conf.bindCredentials ?? '')
|
||||||
|
|
||||||
|
const found = await search.search(searchBase, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter: searchFilter.replaceAll('{{username}}', Filter.escape(username)),
|
||||||
|
sizeLimit: 2,
|
||||||
|
...this.attributeOptions()
|
||||||
|
})
|
||||||
|
/*
|
||||||
|
Exactly one entry, or nobody signs in. More than one means the filter does not identify a
|
||||||
|
person — and then binding as "the first" of them would be authenticating whichever entry the
|
||||||
|
directory happened to return first.
|
||||||
|
*/
|
||||||
|
if (found.searchEntries.length !== 1) {
|
||||||
|
throw new Error('ERR_LOGIN_FAILED')
|
||||||
|
}
|
||||||
|
const entry = found.searchEntries[0]
|
||||||
|
|
||||||
|
await this.verifyPassword(entry.dn, password)
|
||||||
|
|
||||||
|
const id = this.attr(entry, this.conf.mappingUID || 'uid')
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
||||||
|
}
|
||||||
|
const email = this.attr(entry, this.conf.mappingEmail || 'mail')
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name: this.attr(entry, this.conf.mappingDisplayName || 'displayName') || email,
|
||||||
|
pictureData: this.pictureFrom(entry),
|
||||||
|
...(this.conf.mapGroups === true
|
||||||
|
? {
|
||||||
|
groups: await this.groupsFor(search, entry),
|
||||||
|
groupsExclusive: this.conf.unassignMissingGroups === true
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
throw this.asLoginError(err)
|
||||||
|
} finally {
|
||||||
|
await this.release(search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connection to the directory, encrypted as the configuration asks.
|
||||||
|
*
|
||||||
|
* `ldaps://` is encrypted from the first byte and takes the TLS options as it connects; StartTLS
|
||||||
|
* opens in the clear and upgrades before anything is sent, which is a request of its own and so a
|
||||||
|
* second round trip. Both end up at the same place, and which one a directory offers is not this
|
||||||
|
* module's business — the URL says.
|
||||||
|
*/
|
||||||
|
private async connect(): Promise<Client> {
|
||||||
|
const secure = this.conf.url.toLowerCase().startsWith('ldaps://')
|
||||||
|
// -> StartTLS on an `ldaps://` URL would be upgrading a connection that is already encrypted
|
||||||
|
const startTls = this.conf.tlsEnabled === true && !secure
|
||||||
|
const tlsOptions = secure || startTls ? await this.tlsOptions() : undefined
|
||||||
|
const conn = new Client({
|
||||||
|
url: this.conf.url,
|
||||||
|
timeout: OPERATION_TIMEOUT_MS,
|
||||||
|
connectTimeout: OPERATION_TIMEOUT_MS,
|
||||||
|
/*
|
||||||
|
Only for a URL that is asking for TLS from the outset. `ldapts` reads any non-empty
|
||||||
|
`tlsOptions` as "connect with TLS" whatever the scheme says, so handing them over on a plain
|
||||||
|
connection opens one with a ClientHello to a server expecting LDAP — which is a hang and then
|
||||||
|
a parse error, not a helpful failure. StartTLS gets them on the upgrade instead, which is
|
||||||
|
where they belong: the point of it is that the connection starts in the clear.
|
||||||
|
*/
|
||||||
|
...(secure ? { tlsOptions } : {})
|
||||||
|
})
|
||||||
|
if (startTls) {
|
||||||
|
await conn.startTLS(tlsOptions)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the directory's certificate is treated.
|
||||||
|
*
|
||||||
|
* An extra CA is added to the system's own rather than replacing it, so a directory behind an
|
||||||
|
* internal authority is trusted without a wiki losing every public one — and it is only read at
|
||||||
|
* all when the certificate is being verified, since there is nothing for it to say otherwise.
|
||||||
|
*/
|
||||||
|
private async tlsOptions(): Promise<ConnectionOptions> {
|
||||||
|
const rejectUnauthorized = this.conf.verifyTLSCertificate !== false
|
||||||
|
if (!rejectUnauthorized || !this.conf.tlsCertPath) {
|
||||||
|
return { rejectUnauthorized }
|
||||||
|
}
|
||||||
|
if (!this.ca) {
|
||||||
|
this.ca = [await fs.readFile(this.conf.tlsCertPath)]
|
||||||
|
}
|
||||||
|
return { rejectUnauthorized, ca: this.ca }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the directory to bind as the entry, with the password that was typed.
|
||||||
|
*
|
||||||
|
* On its own connection, closed straight afterwards: this is the only place the password goes, and
|
||||||
|
* a connection bound as somebody else has no further use here.
|
||||||
|
*/
|
||||||
|
private async verifyPassword(dn: string, password: string): Promise<void> {
|
||||||
|
const asUser = await this.connect()
|
||||||
|
try {
|
||||||
|
await asUser.bind(dn, password)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof InvalidCredentialsError) {
|
||||||
|
throw new Error('ERR_LOGIN_FAILED')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
await this.release(asUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of the groups the directory puts this entry in.
|
||||||
|
*
|
||||||
|
* Read with the wiki's own read-only account, on the connection the user entry was found with.
|
||||||
|
* A group search that fails is a failed login rather than a login with no groups: under
|
||||||
|
* `unassignMissingGroups` the empty answer would be indistinguishable from the directory saying
|
||||||
|
* this person belongs to nothing, and would take every mapped membership away.
|
||||||
|
*/
|
||||||
|
private async groupsFor(search: Client, entry: Entry): Promise<string[]> {
|
||||||
|
const { groupSearchBase, groupSearchFilter } = this.conf
|
||||||
|
if (!groupSearchBase || !groupSearchFilter) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
const nameField = this.conf.groupNameField || 'name'
|
||||||
|
const dnProperty = this.conf.groupDnProperty || 'dn'
|
||||||
|
const dnValue = dnProperty === 'dn' ? entry.dn : this.attr(entry, dnProperty)
|
||||||
|
if (!dnValue) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await search.search(groupSearchBase, {
|
||||||
|
scope: (this.conf.groupSearchScope || 'sub') as SearchOptions['scope'],
|
||||||
|
filter: groupSearchFilter.replaceAll('{{dn}}', Filter.escape(dnValue)),
|
||||||
|
attributes: [nameField]
|
||||||
|
})
|
||||||
|
return found.searchEntries
|
||||||
|
.map((grp) => this.attr(grp, nameField))
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which attributes to ask for.
|
||||||
|
*
|
||||||
|
* All of the user ones, because the four mappings are configurable and a directory holds far more
|
||||||
|
* than the wiki knows to name — plus the picture as a buffer, since asking for `jpegPhoto` as a
|
||||||
|
* string is asking for an image decoded as UTF-8.
|
||||||
|
*/
|
||||||
|
private attributeOptions(): Pick<SearchOptions, 'attributes' | 'explicitBufferAttributes'> {
|
||||||
|
const picture = this.conf.mappingPicture
|
||||||
|
return {
|
||||||
|
attributes: ['*'],
|
||||||
|
...(picture ? { explicitBufferAttributes: [picture] } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attribute of an entry, as a string.
|
||||||
|
*
|
||||||
|
* LDAP attributes are multi-valued, and a directory is free to answer with one value or a list of
|
||||||
|
* them for the same attribute — a person with two addresses in `mail` is ordinary. The first is
|
||||||
|
* taken, which is the same choice every LDAP-backed application makes.
|
||||||
|
*/
|
||||||
|
private attr(entry: Entry, name: string): string | undefined {
|
||||||
|
const value = entry[name]
|
||||||
|
const first = Array.isArray(value) ? value[0] : value
|
||||||
|
if (first === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const text = Buffer.isBuffer(first) ? first.toString('utf8') : first
|
||||||
|
return text.trim().length > 0 ? text.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The photo held in the entry, when the configuration names an attribute holding one. */
|
||||||
|
private pictureFrom(entry: Entry): Buffer | undefined {
|
||||||
|
const name = this.conf.mappingPicture
|
||||||
|
if (!name) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const value = entry[name]
|
||||||
|
const first = Array.isArray(value) ? value[0] : value
|
||||||
|
return Buffer.isBuffer(first) && first.length > 0 ? first : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn whatever the directory or the network raised into a code the login screen can put in front
|
||||||
|
* of somebody.
|
||||||
|
*
|
||||||
|
* An `ERR_` message is already one and is passed through. Anything else is the directory being
|
||||||
|
* unreachable, misconfigured or unhappy, which is not the person's fault and must not read as a
|
||||||
|
* wrong password — so it is logged as itself and reported as a provider failure.
|
||||||
|
*/
|
||||||
|
private asLoginError(err: any): Error {
|
||||||
|
if (typeof err?.message === 'string' && err.message.startsWith('ERR_')) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// -> The class name as well as the message: `ldapts` raises a result-code error whose message is
|
||||||
|
// only the code, and "InvalidCredentialsError" is what says the wiki's own bind DN is wrong
|
||||||
|
WIKI.logger.warn(
|
||||||
|
`LDAP strategy ${this.strategyId} could not complete a login: ${err.name}: ${err.message}`
|
||||||
|
)
|
||||||
|
return new Error('ERR_PROVIDER_REQUEST_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a connection without letting the close itself fail a login that already succeeded. */
|
||||||
|
private async release(conn: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
await conn.unbind()
|
||||||
|
} catch (err: any) {
|
||||||
|
WIKI.logger.debug(`LDAP strategy ${this.strategyId} could not unbind cleanly: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
key: ldap
|
||||||
|
title: LDAP / Active Directory
|
||||||
|
description: Lightweight Directory Access Protocol, as spoken by Active Directory, OpenLDAP, FreeIPA and everything else that holds a directory of people.
|
||||||
|
author: requarks.io
|
||||||
|
logo: https://static.requarks.io/logo/active-directory.svg
|
||||||
|
icon: /_assets/icons/ultraviolet-windows8.svg
|
||||||
|
color: blue-grey-7
|
||||||
|
isAvailable: true
|
||||||
|
useForm: true
|
||||||
|
usernameType: username
|
||||||
|
props:
|
||||||
|
url:
|
||||||
|
type: String
|
||||||
|
title: LDAP URL
|
||||||
|
hint: e.g. ldap://directory.example.com:389, or ldaps://directory.example.com:636 for a connection that is encrypted from the start.
|
||||||
|
icon: internet
|
||||||
|
default: 'ldap://localhost:389'
|
||||||
|
order: 1
|
||||||
|
bindDn:
|
||||||
|
type: String
|
||||||
|
title: Admin Bind DN
|
||||||
|
hint: The distinguished name of the account this wiki searches the directory as. It needs to read the user entries and nothing more.
|
||||||
|
icon: administrator-male
|
||||||
|
default: 'cn=readonly,dc=example,dc=com'
|
||||||
|
order: 2
|
||||||
|
bindCredentials:
|
||||||
|
type: String
|
||||||
|
title: Admin Bind Credentials
|
||||||
|
hint: The password of the account above.
|
||||||
|
icon: password
|
||||||
|
sensitive: true
|
||||||
|
order: 3
|
||||||
|
searchBase:
|
||||||
|
type: String
|
||||||
|
title: Search Base
|
||||||
|
hint: The base DN under which to look for the person signing in.
|
||||||
|
icon: folder
|
||||||
|
default: 'ou=people,dc=example,dc=com'
|
||||||
|
order: 4
|
||||||
|
searchFilter:
|
||||||
|
type: String
|
||||||
|
title: Search Filter
|
||||||
|
hint: How a username is turned into one entry. `{{username}}` must appear and is substituted with what was typed, escaped. e.g. (uid={{username}}) or (sAMAccountName={{username}}).
|
||||||
|
icon: search
|
||||||
|
default: '(uid={{username}})'
|
||||||
|
order: 5
|
||||||
|
tlsEnabled:
|
||||||
|
type: Boolean
|
||||||
|
title: Use StartTLS
|
||||||
|
hint: Upgrade a plain `ldap://` connection to TLS before anything is sent over it. Leave off for an `ldaps://` URL, which is encrypted already.
|
||||||
|
icon: security-ssl
|
||||||
|
default: false
|
||||||
|
order: 6
|
||||||
|
verifyTLSCertificate:
|
||||||
|
type: Boolean
|
||||||
|
title: Verify TLS Certificate
|
||||||
|
hint: Check the directory's certificate against the trusted authorities. Turning this off means the connection is encrypted but the server is not identified, which is no protection at all against something sitting in the middle of it.
|
||||||
|
icon: security-configuration
|
||||||
|
default: true
|
||||||
|
order: 7
|
||||||
|
tlsCertPath:
|
||||||
|
type: String
|
||||||
|
title: TLS Certificate Path
|
||||||
|
hint: (optional) Absolute path, on the server, to the PEM certificate authority to trust in addition to the system's own. For a directory using an internal CA.
|
||||||
|
icon: fingerprint-scan
|
||||||
|
order: 8
|
||||||
|
mappingUID:
|
||||||
|
type: String
|
||||||
|
title: Unique ID Field Mapping
|
||||||
|
hint: The attribute holding the directory's own identifier for the entry. Usually "uid" or "sAMAccountName". It has to be one that is never reassigned.
|
||||||
|
icon: key
|
||||||
|
default: 'uid'
|
||||||
|
order: 20
|
||||||
|
mappingEmail:
|
||||||
|
type: String
|
||||||
|
title: Email Field Mapping
|
||||||
|
hint: The attribute holding the email address, usually "mail". An account here is matched on it, so an entry without one cannot sign in.
|
||||||
|
icon: envelope
|
||||||
|
default: 'mail'
|
||||||
|
order: 21
|
||||||
|
mappingDisplayName:
|
||||||
|
type: String
|
||||||
|
title: Display Name Field Mapping
|
||||||
|
hint: The attribute holding the name to show. Usually "displayName" or "cn". Falls back to the email address when the entry has neither.
|
||||||
|
icon: person
|
||||||
|
default: 'displayName'
|
||||||
|
order: 22
|
||||||
|
mappingPicture:
|
||||||
|
type: String
|
||||||
|
title: Avatar Picture Field Mapping
|
||||||
|
hint: The attribute holding the account's photo, usually "jpegPhoto" or "thumbnailPhoto" — the image itself, not a link to one. Leave empty to let people keep whatever avatar they set here.
|
||||||
|
icon: image
|
||||||
|
default: 'jpegPhoto'
|
||||||
|
order: 23
|
||||||
|
mapGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Map Groups
|
||||||
|
hint: Put the user in the wiki groups their directory groups are named after, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
|
||||||
|
icon: user-groups
|
||||||
|
default: false
|
||||||
|
order: 24
|
||||||
|
groupSearchBase:
|
||||||
|
type: String
|
||||||
|
title: Group Search Base
|
||||||
|
hint: The base DN under which to look for the groups an entry belongs to.
|
||||||
|
icon: folder
|
||||||
|
default: 'ou=groups,dc=example,dc=com'
|
||||||
|
order: 25
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
groupSearchFilter:
|
||||||
|
type: String
|
||||||
|
title: Group Search Filter
|
||||||
|
hint: Which groups count as the user's. `{{dn}}` is substituted with the value of the property below, escaped. (member={{dn}}) is right for most directories.
|
||||||
|
icon: search
|
||||||
|
default: '(member={{dn}})'
|
||||||
|
order: 26
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
groupSearchScope:
|
||||||
|
type: String
|
||||||
|
title: Group Search Scope
|
||||||
|
hint: How far below the Group Search Base to look. `sub` searches the whole subtree, `one` its immediate children, `base` only the entry itself.
|
||||||
|
icon: depth
|
||||||
|
default: sub
|
||||||
|
enum:
|
||||||
|
- base
|
||||||
|
- one
|
||||||
|
- sub
|
||||||
|
order: 27
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
groupDnProperty:
|
||||||
|
type: String
|
||||||
|
title: Group DN Property
|
||||||
|
hint: Which property of the user's entry `{{dn}}` stands for in the filter above. Usually "dn".
|
||||||
|
icon: symlink-directory
|
||||||
|
default: dn
|
||||||
|
order: 28
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
groupNameField:
|
||||||
|
type: String
|
||||||
|
title: Group Name Field
|
||||||
|
hint: The attribute on a group entry holding the name to match a wiki group against. Usually "name" or "cn".
|
||||||
|
icon: rename
|
||||||
|
default: name
|
||||||
|
order: 29
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
unassignMissingGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Unassign from groups no longer present in directory
|
||||||
|
hint: Off adds what the directory says and takes nothing away, so a membership granted here survives. On makes the directory the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
|
||||||
|
icon: unfriend
|
||||||
|
default: false
|
||||||
|
order: 30
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
|
||||||
|
import type { SamlConfig } from '@node-saml/node-saml'
|
||||||
|
import { CustomError } from '../../../helpers/common.ts'
|
||||||
|
import type {
|
||||||
|
AuthFlow,
|
||||||
|
AuthFlowCallback,
|
||||||
|
AuthRequestTarget,
|
||||||
|
ProviderProfile
|
||||||
|
} from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/** The longest a provider's signing key list may be, so a pasted mistake cannot become a loop. */
|
||||||
|
const MAX_CERTS = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAML 2.0
|
||||||
|
*
|
||||||
|
* The Web Browser SSO profile: the wiki sends an AuthnRequest to the identity provider, the provider
|
||||||
|
* authenticates the person and posts a signed assertion back to the callback. What makes it SAML
|
||||||
|
* rather than a redirect with a claim on the end is that assertion — an XML document signed by the
|
||||||
|
* provider's key, restricted to an audience and valid only for a few minutes — and every one of
|
||||||
|
* those properties is checked before a word of it is believed.
|
||||||
|
*
|
||||||
|
* That checking is why this goes through `@node-saml/node-saml`. XML signature verification is not
|
||||||
|
* something to write: the document is canonicalized, the signature covers a subset of it named by
|
||||||
|
* reference, and the ways of getting that wrong — signature wrapping, comment splicing, a signature
|
||||||
|
* over a different element than the one being read — are the entire published history of broken SAML
|
||||||
|
* implementations.
|
||||||
|
*
|
||||||
|
* The assertion arrives as a cross-site form POST, which is why the definition declares
|
||||||
|
* `postCallback` and why the flow this login started travels in a cookie of its own. See
|
||||||
|
* `api/authentication.ts`.
|
||||||
|
*/
|
||||||
|
export default class SamlAuthentication {
|
||||||
|
strategyId: string
|
||||||
|
conf: Record<string, any>
|
||||||
|
/** Set by `models/authentication.ts` right after construction. */
|
||||||
|
module?: string
|
||||||
|
|
||||||
|
constructor(strategyId: string, conf: Record<string, any>) {
|
||||||
|
this.strategyId = strategyId
|
||||||
|
this.conf = conf
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider as `node-saml` sees it.
|
||||||
|
*
|
||||||
|
* Built per request rather than kept, because the ACS URL is derived from the request — an
|
||||||
|
* instance answering on more than one hostname has more than one — and unlike a discovery
|
||||||
|
* document it costs nothing: this is a constructor call over values already in hand.
|
||||||
|
*/
|
||||||
|
private saml(callbackUrl: string): SAML {
|
||||||
|
const { entryPoint, issuer, cert } = this.conf
|
||||||
|
if (!entryPoint || !issuer || !cert) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
const idpCert = String(cert)
|
||||||
|
.split('|')
|
||||||
|
.map((one) => one.trim())
|
||||||
|
.filter((one) => one.length > 0)
|
||||||
|
.slice(0, MAX_CERTS)
|
||||||
|
if (idpCert.length < 1) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: SamlConfig = {
|
||||||
|
callbackUrl,
|
||||||
|
entryPoint,
|
||||||
|
issuer,
|
||||||
|
idpCert,
|
||||||
|
identifierFormat: this.conf.identifierFormat || null,
|
||||||
|
signatureAlgorithm: this.conf.signatureAlgorithm || 'sha256',
|
||||||
|
digestAlgorithm: this.conf.digestAlgorithm || 'sha256',
|
||||||
|
wantAssertionsSigned: this.conf.wantAssertionsSigned !== false,
|
||||||
|
acceptedClockSkewMs: Number.parseInt(this.conf.acceptedClockSkewMs, 10) || 0,
|
||||||
|
disableRequestedAuthnContext: this.conf.disableRequestedAuthnContext === true,
|
||||||
|
authnContext: String(this.conf.authnContext || '')
|
||||||
|
.split('|')
|
||||||
|
.map((one) => one.trim())
|
||||||
|
.filter((one) => one.length > 0),
|
||||||
|
racComparison: this.conf.racComparison || 'exact',
|
||||||
|
forceAuthn: this.conf.forceAuthn === true,
|
||||||
|
passive: this.conf.passive === true,
|
||||||
|
skipRequestCompression: this.conf.skipRequestCompression === true,
|
||||||
|
authnRequestBinding: this.conf.authnRequestBinding || 'HTTP-Redirect',
|
||||||
|
/*
|
||||||
|
Not validated, and it cannot be here. `InResponseTo` is checked against the request IDs this
|
||||||
|
process issued, which in a clustered wiki is the wrong set: the instance that answers the
|
||||||
|
provider's POST is not necessarily the one that sent the request, so an assertion for a
|
||||||
|
perfectly good login would be refused about half the time. The binding between this browser
|
||||||
|
and this answer is the flow's `state`, echoed back as `RelayState` and checked by the route —
|
||||||
|
which every strategy here is held to, whatever its protocol.
|
||||||
|
*/
|
||||||
|
validateInResponseTo: ValidateInResponseTo.never,
|
||||||
|
...(this.conf.providerName ? { providerName: this.conf.providerName } : {}),
|
||||||
|
...(this.conf.audience ? { audience: this.conf.audience } : {}),
|
||||||
|
...(this.conf.privateKey ? { privateKey: this.conf.privateKey } : {}),
|
||||||
|
...(this.conf.decryptionPvk ? { decryptionPvk: this.conf.decryptionPvk } : {})
|
||||||
|
}
|
||||||
|
return new SAML(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to send the browser to sign in.
|
||||||
|
*
|
||||||
|
* The flow's `state` goes as `RelayState`, which the provider echoes back untouched and the route
|
||||||
|
* checks — SAML's equivalent of the `state` an OAuth2 login carries, and the reason a stray
|
||||||
|
* assertion posted at the callback is not a login.
|
||||||
|
*
|
||||||
|
* Which binding produces which answer: Redirect is a URL with the deflated request on its query
|
||||||
|
* string, POST is a page holding a form the browser submits to the provider. Both are answers the
|
||||||
|
* start route knows how to send; see `AuthRequestTarget`.
|
||||||
|
*/
|
||||||
|
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<AuthRequestTarget> {
|
||||||
|
const saml = this.saml(redirectUri)
|
||||||
|
if (this.conf.authnRequestBinding === 'HTTP-POST') {
|
||||||
|
return { html: await saml.getAuthorizeFormAsync(state, undefined, {}) }
|
||||||
|
}
|
||||||
|
return saml.getAuthorizeUrlAsync(state, undefined, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the assertion the provider posted into who signed in.
|
||||||
|
*
|
||||||
|
* `validatePostResponseAsync` is what does the checking: the signature against the provider's
|
||||||
|
* certificate, the audience restriction, the conditions' validity window, and the status the
|
||||||
|
* provider reported. Everything after it is reading attributes.
|
||||||
|
*/
|
||||||
|
async profile({ redirectUri, body }: AuthFlowCallback): Promise<ProviderProfile> {
|
||||||
|
if (!body?.SAMLResponse) {
|
||||||
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
||||||
|
}
|
||||||
|
const saml = this.saml(redirectUri)
|
||||||
|
let profile
|
||||||
|
try {
|
||||||
|
profile = (await saml.validatePostResponseAsync(body)).profile
|
||||||
|
} catch (err: any) {
|
||||||
|
/*
|
||||||
|
What the library says about a rejected assertion goes to the log and no further. Its messages
|
||||||
|
are precise — an invalid signature, an audience that does not match, conditions not yet
|
||||||
|
valid — and precise is exactly what must not be handed back: this endpoint is open to whoever
|
||||||
|
can reach the wiki, and told which check it failed, a forged assertion can be worked on until
|
||||||
|
it passes.
|
||||||
|
*/
|
||||||
|
WIKI.logger.warn(`SAML strategy ${this.strategyId} rejected an assertion: ${err.message}`)
|
||||||
|
throw new Error('ERR_LOGIN_FAILED')
|
||||||
|
}
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Attributes are read off the profile, where `node-saml` puts each of them under its own name
|
||||||
|
alongside the NameID and the rest of the assertion's own fields. A configured mapping is
|
||||||
|
therefore looked up as a plain key, which is what lets it be either a bare attribute name or one
|
||||||
|
of the URI-shaped ones an AD FS or an Entra assertion uses.
|
||||||
|
*/
|
||||||
|
const id = this.attr(profile, this.conf.mappingUID) ?? profile.nameID
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
||||||
|
}
|
||||||
|
const email = this.attr(profile, this.conf.mappingEmail)
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name: this.attr(profile, this.conf.mappingDisplayName) || email,
|
||||||
|
picture: this.attr(profile, this.conf.mappingPicture),
|
||||||
|
...(this.conf.mapGroups === true
|
||||||
|
? {
|
||||||
|
groups: this.groupsFrom(profile),
|
||||||
|
groupsExclusive: this.conf.unassignMissingGroups === true
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This wiki as a service provider, in the form a provider configures itself from.
|
||||||
|
*
|
||||||
|
* Carries the entity ID, the ACS URL and the certificates — the public halves, and only where the
|
||||||
|
* corresponding key is configured, since a provider has nothing to do with a certificate this wiki
|
||||||
|
* never signs or decrypts with. Served by `GET /_api/auth/:strategyId/metadata`.
|
||||||
|
*/
|
||||||
|
async metadata({ callbackUrl }: { callbackUrl: string }): Promise<string> {
|
||||||
|
/*
|
||||||
|
A key with no certificate beside it cannot be described. Refused with a message rather than
|
||||||
|
left to fail inside the library, since the answer is a specific thing to go and do — and a 500
|
||||||
|
on a public endpoint says nothing about which of the two fields is missing.
|
||||||
|
*/
|
||||||
|
if (this.conf.privateKey && !this.conf.signingCert) {
|
||||||
|
throw new CustomError(
|
||||||
|
'samlMetadataIncomplete',
|
||||||
|
'This strategy signs its requests, so its Signing Certificate has to be configured before metadata can describe it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (this.conf.decryptionPvk && !this.conf.decryptionCert) {
|
||||||
|
throw new CustomError(
|
||||||
|
'samlMetadataIncomplete',
|
||||||
|
'This strategy accepts encrypted assertions, so its Decryption Certificate has to be configured before metadata can describe it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.saml(callbackUrl).generateServiceProviderMetadata(
|
||||||
|
this.conf.decryptionCert || null,
|
||||||
|
this.conf.signingCert || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attribute of the assertion, as a string.
|
||||||
|
*
|
||||||
|
* A SAML attribute may carry several values, and `node-saml` hands over an array when it does. The
|
||||||
|
* first is taken. An empty mapping means the administrator has turned that mapping off, which is
|
||||||
|
* not the same as an attribute that happens to be missing.
|
||||||
|
*/
|
||||||
|
private attr(profile: Record<string, any>, name: string | undefined): string | undefined {
|
||||||
|
if (!name) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const value = profile[name]
|
||||||
|
const first = Array.isArray(value) ? value[0] : value
|
||||||
|
if (typeof first !== 'string') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return first.trim().length > 0 ? first.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group names the assertion carries.
|
||||||
|
*
|
||||||
|
* Either one name or a list of them: a provider sending a single group commonly sends the bare
|
||||||
|
* string, and both forms mean the same thing here.
|
||||||
|
*/
|
||||||
|
private groupsFrom(profile: Record<string, any>): string[] {
|
||||||
|
const value = profile[this.conf.mappingGroups || 'memberOf']
|
||||||
|
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
|
||||||
|
return raw
|
||||||
|
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
key: saml
|
||||||
|
title: SAML 2.0
|
||||||
|
description: Security Assertion Markup Language 2.0, the standard for exchanging authentication and authorization data between security domains.
|
||||||
|
author: requarks.io
|
||||||
|
logo: https://static.requarks.io/logo/saml.svg
|
||||||
|
icon: /_assets/icons/ultraviolet-saml.svg
|
||||||
|
color: red-7
|
||||||
|
isAvailable: true
|
||||||
|
useForm: false
|
||||||
|
usernameType: email
|
||||||
|
postCallback: true
|
||||||
|
props:
|
||||||
|
entryPoint:
|
||||||
|
type: String
|
||||||
|
title: Entry Point
|
||||||
|
hint: The identity provider's single sign-on URL, where the browser is sent to log in.
|
||||||
|
icon: enter
|
||||||
|
order: 1
|
||||||
|
issuer:
|
||||||
|
type: String
|
||||||
|
title: Issuer
|
||||||
|
hint: The entity ID this wiki identifies itself to the provider as. Any stable string the provider is told to expect — a URL naming this wiki is the convention.
|
||||||
|
icon: address
|
||||||
|
order: 2
|
||||||
|
audience:
|
||||||
|
type: String
|
||||||
|
title: Audience
|
||||||
|
hint: (optional) The audience an assertion must be restricted to for this wiki to accept it. Defaults to the Issuer above, which is what a provider configured against this wiki will send.
|
||||||
|
icon: team
|
||||||
|
order: 3
|
||||||
|
cert:
|
||||||
|
type: String
|
||||||
|
title: Certificate
|
||||||
|
hint: The provider's public PEM-encoded X.509 signing certificate, which is what every assertion is checked against. Join several with the | pipe symbol where the provider is rotating keys.
|
||||||
|
icon: security-ssl
|
||||||
|
multiline: true
|
||||||
|
order: 4
|
||||||
|
privateKey:
|
||||||
|
type: String
|
||||||
|
title: Private Key
|
||||||
|
hint: (optional) PEM-formatted key this wiki signs its authentication requests with. Only needed by a provider that requires signed requests.
|
||||||
|
icon: key
|
||||||
|
multiline: true
|
||||||
|
sensitive: true
|
||||||
|
order: 5
|
||||||
|
signingCert:
|
||||||
|
type: String
|
||||||
|
title: Signing Certificate
|
||||||
|
hint: The public PEM-encoded X.509 certificate matching the private key above. Required alongside it, because it is what the metadata document publishes for the provider to verify this wiki's requests with.
|
||||||
|
icon: validation
|
||||||
|
multiline: true
|
||||||
|
order: 6
|
||||||
|
decryptionPvk:
|
||||||
|
type: String
|
||||||
|
title: Decryption Private Key
|
||||||
|
hint: (optional) PEM-formatted key used to decrypt encrypted assertions. Only needed by a provider that encrypts them.
|
||||||
|
icon: password
|
||||||
|
multiline: true
|
||||||
|
sensitive: true
|
||||||
|
order: 7
|
||||||
|
decryptionCert:
|
||||||
|
type: String
|
||||||
|
title: Decryption Certificate
|
||||||
|
hint: The public PEM-encoded X.509 certificate matching the decryption key above. Required alongside it, because it is what the metadata document publishes for the provider to encrypt assertions to.
|
||||||
|
icon: security-configuration
|
||||||
|
multiline: true
|
||||||
|
order: 8
|
||||||
|
signatureAlgorithm:
|
||||||
|
type: String
|
||||||
|
title: Signature Algorithm
|
||||||
|
hint: Which algorithm this wiki signs its requests with. SHA-1 is broken and is here only for a provider that accepts nothing else.
|
||||||
|
icon: validation
|
||||||
|
default: sha256
|
||||||
|
enum:
|
||||||
|
- sha256|SHA-256
|
||||||
|
- sha512|SHA-512
|
||||||
|
- sha1|SHA-1 (insecure)
|
||||||
|
order: 9
|
||||||
|
digestAlgorithm:
|
||||||
|
type: String
|
||||||
|
title: Digest Algorithm
|
||||||
|
hint: Which algorithm digests the data being signed. Match it to the signature algorithm unless the provider asks otherwise.
|
||||||
|
icon: sigma
|
||||||
|
default: sha256
|
||||||
|
enum:
|
||||||
|
- sha256|SHA-256
|
||||||
|
- sha512|SHA-512
|
||||||
|
- sha1|SHA-1 (insecure)
|
||||||
|
order: 10
|
||||||
|
identifierFormat:
|
||||||
|
type: String
|
||||||
|
title: Name Identifier Format
|
||||||
|
hint: What kind of name the request asks the provider to identify people by. Leave empty to ask for no particular format, which is what a provider that objects to being asked wants.
|
||||||
|
icon: rename
|
||||||
|
default: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
||||||
|
order: 20
|
||||||
|
wantAssertionsSigned:
|
||||||
|
type: Boolean
|
||||||
|
title: Require Signed Assertions
|
||||||
|
hint: Refuse a response whose assertion is not signed in its own right. Worth leaving on — a signature over the response alone leaves the assertion inside it unprotected.
|
||||||
|
icon: secure
|
||||||
|
default: true
|
||||||
|
order: 21
|
||||||
|
acceptedClockSkewMs:
|
||||||
|
type: Number
|
||||||
|
title: Accepted Clock Skew (ms)
|
||||||
|
hint: How far this server's clock may differ from the provider's before an assertion is judged not yet valid or expired. Set to -1 to stop checking those timestamps entirely, which throws away the assertion's own expiry.
|
||||||
|
icon: timer
|
||||||
|
default: 0
|
||||||
|
order: 22
|
||||||
|
disableRequestedAuthnContext:
|
||||||
|
type: Boolean
|
||||||
|
title: Disable Requested Auth Context
|
||||||
|
hint: Ask for no particular authentication method, rather than the one below. Known to be what AD FS wants.
|
||||||
|
icon: rules
|
||||||
|
default: false
|
||||||
|
order: 23
|
||||||
|
authnContext:
|
||||||
|
type: String
|
||||||
|
title: Auth Context
|
||||||
|
hint: Which authentication method the request asks for. Join several with the | pipe symbol.
|
||||||
|
icon: rules
|
||||||
|
default: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
|
||||||
|
order: 24
|
||||||
|
if:
|
||||||
|
- { key: 'disableRequestedAuthnContext', eq: false }
|
||||||
|
racComparison:
|
||||||
|
type: String
|
||||||
|
title: RAC Comparison Type
|
||||||
|
hint: How the provider is to compare what it actually did against the context asked for.
|
||||||
|
icon: matches
|
||||||
|
default: exact
|
||||||
|
enum:
|
||||||
|
- exact
|
||||||
|
- minimum
|
||||||
|
- maximum
|
||||||
|
- better
|
||||||
|
order: 25
|
||||||
|
if:
|
||||||
|
- { key: 'disableRequestedAuthnContext', eq: false }
|
||||||
|
forceAuthn:
|
||||||
|
type: Boolean
|
||||||
|
title: Force Initial Re-authentication
|
||||||
|
hint: Ask the provider to authenticate the person again even if they already have a session there.
|
||||||
|
icon: renew
|
||||||
|
default: false
|
||||||
|
order: 26
|
||||||
|
passive:
|
||||||
|
type: Boolean
|
||||||
|
title: Passive
|
||||||
|
hint: Ask the provider not to interact with the person at all — so an existing session signs them in and no session sends them straight back.
|
||||||
|
icon: do-not-touch
|
||||||
|
default: false
|
||||||
|
order: 27
|
||||||
|
providerName:
|
||||||
|
type: String
|
||||||
|
title: Provider Name
|
||||||
|
hint: (optional) A human-readable name for this wiki, which a provider may show to the person being asked to log in.
|
||||||
|
icon: website
|
||||||
|
default: Wiki.js
|
||||||
|
order: 28
|
||||||
|
authnRequestBinding:
|
||||||
|
type: String
|
||||||
|
title: Request Binding
|
||||||
|
hint: How the authentication request reaches the provider. Redirect sends the browser straight there; POST answers with a page holding a form that submits itself, which under a content security policy forbidding inline scripts becomes a button the person has to press.
|
||||||
|
icon: share
|
||||||
|
default: 'HTTP-Redirect'
|
||||||
|
enum:
|
||||||
|
- HTTP-Redirect|Redirect
|
||||||
|
- HTTP-POST|POST
|
||||||
|
order: 29
|
||||||
|
skipRequestCompression:
|
||||||
|
type: Boolean
|
||||||
|
title: Skip Request Compression
|
||||||
|
hint: Send the authentication request uncompressed. The Redirect binding requires it to be deflated, so this is for a provider that wants otherwise.
|
||||||
|
icon: downloads
|
||||||
|
default: false
|
||||||
|
order: 30
|
||||||
|
mappingUID:
|
||||||
|
type: String
|
||||||
|
title: Unique ID Field Mapping
|
||||||
|
hint: The attribute holding the provider's own identifier for the account. Falls back to the assertion's NameID, which is what most providers identify people by.
|
||||||
|
icon: key
|
||||||
|
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
|
||||||
|
order: 40
|
||||||
|
mappingEmail:
|
||||||
|
type: String
|
||||||
|
title: Email Field Mapping
|
||||||
|
hint: The attribute holding the email address. An account here is matched on it, so an assertion without one cannot sign anybody in.
|
||||||
|
icon: envelope
|
||||||
|
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
|
||||||
|
order: 41
|
||||||
|
mappingDisplayName:
|
||||||
|
type: String
|
||||||
|
title: Display Name Field Mapping
|
||||||
|
hint: The attribute holding the name to show. Falls back to the email address when the assertion has neither.
|
||||||
|
icon: person
|
||||||
|
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
|
||||||
|
order: 42
|
||||||
|
mappingPicture:
|
||||||
|
type: String
|
||||||
|
title: Avatar Picture Field Mapping
|
||||||
|
hint: The attribute holding the URL of the account's picture, fetched on login and stored as the avatar. Leave empty to let people keep whatever avatar they set here.
|
||||||
|
icon: image
|
||||||
|
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/picture'
|
||||||
|
order: 43
|
||||||
|
mapGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Map Groups
|
||||||
|
hint: Put the user in the wiki groups the attribute below names, on every login. Only groups that already exist here are matched, by name and ignoring case — nothing is created.
|
||||||
|
icon: user-groups
|
||||||
|
default: false
|
||||||
|
order: 44
|
||||||
|
mappingGroups:
|
||||||
|
type: String
|
||||||
|
title: User Groups Field Mapping
|
||||||
|
hint: The attribute holding the groups. Either one name or a list of them.
|
||||||
|
icon: rules
|
||||||
|
default: 'memberOf'
|
||||||
|
order: 45
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
unassignMissingGroups:
|
||||||
|
type: Boolean
|
||||||
|
title: Unassign from groups no longer present in assertion
|
||||||
|
hint: Off adds what the assertion names and takes nothing away, so a membership granted here survives. On makes the provider the authority instead, and a group it stops naming is taken back — bar the ones this strategy auto-enrolls into, which are granted here to everyone it lets in.
|
||||||
|
icon: unfriend
|
||||||
|
default: false
|
||||||
|
order: 46
|
||||||
|
if:
|
||||||
|
- { key: 'mapGroups', eq: true }
|
||||||
|
refs:
|
||||||
|
callbackUrl:
|
||||||
|
title: Assertion Consumer Service URL
|
||||||
|
hint: Register this with the provider as where to post assertions. Also called the Reply URL or the ACS URL, depending on whose interface you are in.
|
||||||
|
icon: back
|
||||||
|
value: '{host}/_api/auth/{id}/callback'
|
||||||
|
metadataUrl:
|
||||||
|
title: Service Provider Metadata
|
||||||
|
hint: Hand this to a provider that configures itself from a metadata document rather than from pasted values.
|
||||||
|
icon: rescan-document
|
||||||
|
value: '{host}/_api/auth/{id}/metadata'
|
||||||
Loading…
Reference in new issue