mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
292 lines
12 KiB
292 lines
12 KiB
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
|
|
import type { SamlConfig } from '@node-saml/node-saml'
|
|
import { missingSettings, strategyDebug } from '../../../helpers/authDebug.ts'
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
`is not configured: ${missingSettings({ 'Login URL': entryPoint, 'Issuer / Entity ID': issuer, "Identity Provider's Certificate": 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) {
|
|
strategyDebug(
|
|
this,
|
|
"the Identity Provider's Certificate holds no certificate, so no assertion can be verified"
|
|
)
|
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
|
}
|
|
strategyDebug(
|
|
this,
|
|
`assertions are expected from ${entryPoint} for audience \`${this.conf.audience || issuer}\`, verified against ${idpCert.length} certificate(s), posted back to ${callbackUrl}`
|
|
)
|
|
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
`the callback carried no SAMLResponse. It carried: ${Object.keys(body ?? {}).join(', ') || 'nothing'}`
|
|
)
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
'the assertion verified but carried no subject, so there is nobody in it to sign in'
|
|
)
|
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
|
}
|
|
// -> The names and not the values: which attributes an identity provider actually asserts is what
|
|
// the four Field Mapping settings have to be chosen from, and is never quite what its
|
|
// documentation says — AD FS and Entra both send URI-shaped ones
|
|
strategyDebug(this, `the assertion carries: ${Object.keys(profile).join(', ')}`)
|
|
|
|
/*
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
`neither \`${this.conf.mappingUID || '(no Unique ID mapping)'}\` nor the NameID identifies this account`
|
|
)
|
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
|
}
|
|
const email = this.attr(profile, this.conf.mappingEmail)
|
|
if (!email) {
|
|
strategyDebug(
|
|
this,
|
|
`\`${this.conf.mappingEmail || '(no Email mapping)'}\` carries no address, and an account here is matched by address`
|
|
)
|
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
|
}
|
|
const groups = this.conf.mapGroups === true ? this.groupsFrom(profile) : undefined
|
|
strategyDebug(
|
|
this,
|
|
`${id} signs in as <${email}>${groups ? `, in ${groups.length} asserted group(s)` : ', groups not mapped'}`
|
|
)
|
|
return {
|
|
id,
|
|
email,
|
|
name: this.attr(profile, this.conf.mappingDisplayName) || email,
|
|
picture: this.attr(profile, this.conf.mappingPicture),
|
|
...(groups
|
|
? {
|
|
groups,
|
|
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 attribute = this.conf.mappingGroups || 'memberOf'
|
|
const value = profile[attribute]
|
|
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []
|
|
const names = raw
|
|
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
.map((entry) => entry.trim())
|
|
/*
|
|
Logged even when it is empty, and especially then: a provider asserts group membership only for
|
|
a relying party configured to receive it, and an empty answer is not distinguishable on the
|
|
wiki side from somebody genuinely being in no group.
|
|
*/
|
|
strategyDebug(
|
|
this,
|
|
`\`${attribute}\` names ${names.length} group(s): ${names.join(', ') || 'none'}`
|
|
)
|
|
return names
|
|
}
|
|
}
|