Skip to content

Commit 924473e

Browse files
authored
feat: saml identity providers configured per site (#78)
1 parent 5c20e17 commit 924473e

File tree

14 files changed

+324
-231
lines changed

14 files changed

+324
-231
lines changed

Dockerfile

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
##########################
2-
FROM node:22.11.0-alpine3.20 AS base
2+
FROM node:22.13.1-alpine3.21 AS base
33

4-
RUN npm install -g npm@10.9.1
4+
RUN npm install -g npm@11.1.0
55

66
WORKDIR /app
77
ENV NODE_ENV=production
@@ -59,7 +59,6 @@ RUN npm -w ui run build
5959
##########################
6060
FROM installer AS api-installer
6161

62-
# remove other workspaces and reinstall, otherwise we can get rig have some peer dependencies from other workspaces
6362
RUN npm ci -w api --prefer-offline --omit=dev --omit=optional --omit=peer --no-audit --no-fund && \
6463
npx clean-modules --yes "!ramda/src/test.js"
6564
RUN mkdir -p /app/api/node_modules
@@ -77,8 +76,13 @@ COPY --from=types /app/api/config api/config
7776
COPY --from=api-installer /app/api/node_modules api/node_modules
7877
COPY --from=ui /app/ui/dist ui/dist
7978
ADD package.json README.md LICENSE BUILD.json* ./
79+
8080
EXPOSE 8080
8181
EXPOSE 9090
82+
8283
USER node
8384
WORKDIR /app/api
85+
86+
ENV DEBUG upgrade*
87+
8488
CMD ["node", "--max-http-header-size", "65536", "--experimental-strip-types", "index.ts"]

api/config/type/schema.json

+7-52
Original file line numberDiff line numberDiff line change
@@ -543,11 +543,7 @@
543543
"oidcProvider": {
544544
"type": "object",
545545
"title": "OpenID Connect",
546-
"required": [
547-
"title",
548-
"discovery",
549-
"client"
550-
],
546+
"required": [ "title" ],
551547
"allOf": [
552548
{
553549
"properties": {
@@ -563,59 +559,18 @@
563559
},
564560
"saml2Provider": {
565561
"type": "object",
566-
"title": "Saml 2",
567-
"required": [
568-
"title"
569-
],
570-
"properties": {
571-
"metadata": { "type": "string" },
572-
"title": { "type": "string" },
573-
"color": { "type": "string" },
574-
"icon": { "type": "string" },
575-
"img": { "type": "string" },
576-
"coreIdProvider": {
577-
"type": "boolean"
578-
},
579-
"redirectMode": {
580-
"$ref": "#/$defs/redirectMode"
581-
}
582-
}
583-
},
584-
"redirectMode": {
585-
"type": "object",
586-
"description": "Si vous utilisez un mode basé sur l'email alors la mire d'authentification demandera l'email de l'utilisateur en 1ère étape.",
587-
"default": {
588-
"type": "button"
589-
},
590-
"oneOf": [
591-
{
592-
"title": "bouton",
593-
"properties": {
594-
"type": {
595-
"const": "button",
596-
"title": "Controlez la manière dont les utilisateurs sont redirigés vers ce fournisseur"
597-
}
598-
}
599-
},
562+
"title": "SAML 2",
563+
"required": [ "title" ],
564+
"allOf": [
600565
{
601-
"title": "redirection auto quand l'email appartient à un nom de domaine",
602566
"properties": {
603-
"type": {
604-
"const": "emailDomain"
605-
},
606-
"emailDomain": {
607-
"type": "string",
608-
"title": "nom de domaine de l'email"
567+
"title": {
568+
"type": "string"
609569
}
610570
}
611571
},
612572
{
613-
"title": "toujours rediriger implicitement",
614-
"properties": {
615-
"type": {
616-
"const": "always"
617-
}
618-
}
573+
"$ref": "https://github.com/data-fair/simple-directory/site#/$defs/saml2Provider"
619574
}
620575
]
621576
},

api/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"#i18n": "./i18n/index.ts"
1818
},
1919
"dependencies": {
20-
"@data-fair/lib-express": "^1.10.1",
20+
"@data-fair/lib-express": "^1.12.6",
2121
"@data-fair/lib-node": "^2.3.1",
2222
"@data-fair/lib-utils": "^1.2.0",
2323
"@sd/shared": "*",

api/src/auth/providers.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSiteByUrl } from '#services'
1+
import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSamlConfigId, getSiteByUrl } from '#services'
22
import type { Site, PublicAuthProvider } from '#types'
33
import _slug from 'slugify'
44

@@ -12,7 +12,6 @@ export const publicGlobalProviders = async () => {
1212
id: p.id,
1313
title: p.title,
1414
color: p.color,
15-
icon: p.icon,
1615
img: p.img,
1716
redirectMode: p.redirectMode
1817
})
@@ -45,6 +44,16 @@ export const publicSiteProviders = async (site: Site) => {
4544
redirectMode: p.redirectMode
4645
})
4746
}
47+
if (p.type === 'saml2') {
48+
providers.push({
49+
type: p.type,
50+
id: getSamlConfigId(p),
51+
title: p.title as string,
52+
color: p.color,
53+
img: p.img,
54+
redirectMode: p.redirectMode
55+
})
56+
}
4857
if (p.type === 'otherSite') {
4958
const otherSiteUrl = (p.site.startsWith('http://') || p.site.startsWith('https://')) ? p.site : `https://${p.site}`
5059
const otherSite = await getSiteByUrl(otherSiteUrl)

api/src/auth/router.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import bodyParser from 'body-parser'
66
import { nanoid } from 'nanoid'
77
import Cookies from 'cookies'
88
import Debug from 'debug'
9-
import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByUrl, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, logout, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getOrgLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider, initServerSession, getRedirectSite } from '#services'
9+
import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByUrl, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, logout, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getOrgLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider, initServerSession, getRedirectSite, getSamlProviderById } from '#services'
1010
import type { SdStorage } from '../storages/interface.ts'
1111
import type { ActionPayload, ServerSession, User, UserWritable } from '#types'
1212
import eventsLog, { type EventLogContext } from '@data-fair/lib-express/events-log.js'
@@ -803,17 +803,17 @@ router.post('/oauth-logout', oauthLogoutCallback)
803803
const debugSAML = Debug('saml')
804804

805805
// expose metadata to declare ourselves to identity provider
806-
router.get('/saml2-metadata.xml', (req, res) => {
806+
router.get('/saml2-metadata.xml', async (req, res) => {
807807
res.type('application/xml')
808-
res.send(saml2ServiceProvider().getMetadata())
808+
res.send((await saml2ServiceProvider(await reqSite(req))).getMetadata())
809809
})
810810

811811
// starts login
812812
router.get('/saml2/:providerId/login', async (req, res) => {
813813
const logContext: EventLogContext = { req }
814814

815815
debugSAML('login request', req.params.providerId)
816-
const provider = saml2GlobalProviders().find(p => p.id === req.params.providerId)
816+
const provider = await getSamlProviderById(req, req.params.providerId)
817817
if (!provider) {
818818
eventsLog.info('sd.auth.saml.fail', 'a user tried to login with an unknown saml provider', logContext)
819819
return res.redirect(`${reqSiteUrl(req) + '/simple-directory'}/login?error=unknownSAMLProvider`)
@@ -829,7 +829,7 @@ router.get('/saml2/:providerId/login', async (req, res) => {
829829
]
830830
// relay state should be a request level parameter but it is not in current version of samlify
831831
// cf https://github.com/tngan/samlify/issues/163
832-
const sp = saml2ServiceProvider()
832+
const sp = await saml2ServiceProvider(await reqSite(req))
833833
sp.entitySetting.relayState = JSON.stringify(relayState)
834834

835835
// TODO: apply nameid parameter ? { nameid: req.query.email }
@@ -849,7 +849,7 @@ router.post('/saml2-assert', async (req, res) => {
849849
const site = await reqSite(req)
850850
const storage = storages.globalStorage
851851
const providers = saml2GlobalProviders()
852-
const sp = saml2ServiceProvider()
852+
const sp = await saml2ServiceProvider(await reqSite(req))
853853

854854
let provider
855855
const referer = (req.headers.referer || req.headers.referrer) as string | undefined

api/src/saml2/service.ts

+78-30
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// useful tutorial
22
// https://medium.com/disney-streaming/setup-a-single-sign-on-saml-test-environment-with-docker-and-nodejs-c53fc1a984c9
33

4+
import type { Request } from 'express'
45
import { readFile, access, constants } from 'node:fs/promises'
5-
import type { Saml2 } from '../../config/type/index.ts'
6+
import type { SAML2 } from '../../config/type/index.ts'
67
import config from '#config'
78
import _slug from 'slugify'
89
import samlify from 'samlify'
@@ -11,14 +12,16 @@ import mongo from '#mongo'
1112
import { decipher, cipher } from '../utils/cipher.ts'
1213
import { exec } from 'node:child_process'
1314
import { promisify } from 'node:util'
15+
import { getSiteBaseUrl, reqSite } from '#services'
16+
import { type Site } from '#types'
1417

1518
const execAsync = promisify(exec)
1619
const debug = Debug('saml')
1720
const slug = _slug.default
1821

1922
type Certificates = { signing: { privateKey: string, cert: string }, encrypt: { privateKey: string, cert: string } }
2023

21-
type PreparedSaml2Provider = Saml2 & { id: string, idp: samlify.IdentityProviderInstance }
24+
type PreparedSaml2Provider = SAML2 & { id: string, idp: samlify.IdentityProviderInstance }
2225

2326
// const validator = require('@authenio/samlify-xsd-schema-validator')
2427
// samlify.setSchemaValidator(validator)
@@ -29,6 +32,27 @@ samlify.setSchemaValidator({
2932
}
3033
})
3134

35+
export const getSamlProviderById = async (req: Request, id: string): Promise<PreparedSaml2Provider | undefined> => {
36+
const site = await reqSite(req)
37+
if (!site) {
38+
return saml2GlobalProviders().find(p => p.id === id)
39+
} else {
40+
const providerInfo = site.authProviders?.find(p => p.type === 'saml2' && getSamlConfigId(p) === id) as SAML2
41+
const idp = samlify.IdentityProvider(providerInfo)
42+
return {
43+
id,
44+
...providerInfo,
45+
idp
46+
}
47+
}
48+
}
49+
50+
export const getSamlConfigId = (providerConfig: SAML2) => {
51+
const idp = samlify.IdentityProvider(providerConfig)
52+
if (!idp.entityMeta.meta.entityID) throw new Error('missing entityID in saml IDP metadata')
53+
return getSamlProviderId(idp.entityMeta.meta.entityID)
54+
}
55+
3256
export const getSamlProviderId = (url: string) => {
3357
return slug(new URL(url).host, { lower: true, strict: true })
3458
}
@@ -56,8 +80,16 @@ const readDeprecatedCertificates = async (): Promise<undefined | Certificates> =
5680
}
5781
}
5882

59-
const readCertificates = async (): Promise<undefined | Certificates> => {
60-
const secret = await mongo.secrets.findOne({ _id: 'saml-certificates' })
83+
const getSiteCertKey = (site?: Site) => {
84+
if (!site) return 'saml-certificates'
85+
let key = 'saml-certificates-' + slug(site.host)
86+
if (site.path) key += `--${slug(site.path)}`
87+
return key
88+
}
89+
90+
const readCertificates = async (site?: Site): Promise<undefined | Certificates> => {
91+
const key = getSiteCertKey(site)
92+
const secret = await mongo.secrets.findOne({ _id: key })
6193
if (secret) {
6294
const certificates = secret.data
6395
certificates.signing.privateKey = decipher(certificates.signing.privateKey)
@@ -66,52 +98,47 @@ const readCertificates = async (): Promise<undefined | Certificates> => {
6698
}
6799
}
68100

69-
const writeCertificates = async (certificates: Certificates) => {
101+
const writeCertificates = async (certificates: Certificates, site?: Site) => {
102+
const key = getSiteCertKey(site)
70103
const storedCertificates = { signing: { ...certificates.signing }, encrypt: { ...certificates.encrypt } } as any
71104
storedCertificates.signing.privateKey = cipher(certificates.signing.privateKey)
72105
storedCertificates.encrypt.privateKey = cipher(certificates.encrypt.privateKey)
73-
await mongo.secrets.insertOne({ _id: 'saml-certificates', data: storedCertificates })
106+
await mongo.secrets.insertOne({ _id: key, data: storedCertificates })
74107
}
75108

76109
const _globalProviders: PreparedSaml2Provider[] = []
77110
let _sp: samlify.ServiceProviderInstance | undefined
78-
export const saml2ServiceProvider = () => {
79-
if (!_sp) throw new Error('Global Saml 2 providers ware not initialized')
111+
export const saml2ServiceProvider = async (site?: Site) => {
112+
if (!_sp) throw new Error('Global Saml 2 provider was not initialized')
113+
if (site) return await initServiceProvider(site)
80114
return _sp
81115
}
82116
export const saml2GlobalProviders = () => {
83-
if (!_sp) throw new Error('Global Saml 2 providers ware not initialized')
117+
if (!_sp) throw new Error('Global Saml 2 provider was not initialized')
84118
return _globalProviders
85119
}
86120

87-
export const init = async () => {
88-
let certificates = await readCertificates()
121+
const initCertificates = async (site?: Site) => {
122+
let certificates = await readCertificates(site)
89123
if (!certificates) {
90124
console.log('Initializing SAML certificates')
91-
certificates = await readDeprecatedCertificates()
92-
if (certificates) {
93-
console.log('Migrating SAML certificates from filesystem to database')
94-
} else {
125+
if (!site) {
126+
certificates = await readDeprecatedCertificates()
127+
if (certificates) {
128+
console.log('Migrating SAML certificates from filesystem to database')
129+
}
130+
}
131+
if (!certificates) {
95132
console.log('Generating new SAML certificates')
96133
certificates = { signing: await createCert(), encrypt: await createCert() }
97134
}
98-
await writeCertificates(certificates)
135+
await writeCertificates(certificates, site)
99136
}
137+
return certificates
138+
}
100139

101-
const assertionConsumerService = [{
102-
Binding: samlify.Constants.namespace.binding.post,
103-
Location: `${config.publicUrl}/api/auth/saml2-assert`
104-
}]
105-
debug('config service provider')
106-
_sp = samlify.ServiceProvider({
107-
entityID: `${config.publicUrl}/api/auth/saml2-metadata.xml`,
108-
assertionConsumerService,
109-
signingCert: certificates.signing.cert,
110-
privateKey: certificates.signing.privateKey,
111-
encryptCert: certificates.encrypt.cert,
112-
encPrivateKey: certificates.encrypt.privateKey,
113-
...config.saml2.sp
114-
})
140+
export const init = async () => {
141+
_sp = await initServiceProvider()
115142

116143
debug('config identity providers')
117144
for (const providerConfig of config.saml2.providers) {
@@ -126,6 +153,27 @@ export const init = async () => {
126153
}
127154
}
128155

156+
export const initServiceProvider = async (site?: Site) => {
157+
const certificates = await initCertificates(site)
158+
const url = site ? `${getSiteBaseUrl(site)}/simple-directory` : config.publicUrl
159+
const assertionConsumerService = [{
160+
Binding: samlify.Constants.namespace.binding.post,
161+
Location: `${url}/api/auth/saml2-assert`
162+
}]
163+
debug('config service provider')
164+
return samlify.ServiceProvider({
165+
entityID: `${url}/api/auth/saml2-metadata.xml`,
166+
assertionConsumerService,
167+
signingCert: certificates.signing.cert,
168+
privateKey: certificates.signing.privateKey,
169+
encryptCert: certificates.encrypt.cert,
170+
encPrivateKey: certificates.encrypt.privateKey,
171+
// @ts-ignore if we use a boolean the attribute is set as empty in the xml output, and some IDP don't like that
172+
allowCreate: 'false',
173+
...config.saml2.sp
174+
})
175+
}
176+
129177
const createCert = async () => {
130178
const subject = `/C=FR/CN=${new URL(config.publicUrl).hostname}`
131179
const privateKey = (await execAsync('openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048')).stdout

api/src/services.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ export * from './limits/service.ts'
66
export * from './mails/service.ts'
77
export { initOidcProvider, oauthGlobalProviders, getOidcProviderId, getOAuthProviderById, getOAuthProviderByState } from './oauth/service.ts'
88
export * from './oauth-tokens/service.ts'
9-
export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId } from './saml2/service.ts'
10-
export { reqSite, getSiteByUrl, getRedirectSite } from './sites/service.ts'
9+
export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId, getSamlConfigId, getSamlProviderById } from './saml2/service.ts'
10+
export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl } from './sites/service.ts'
1111
export * from './tokens/service.ts'
1212
export * from './utils/passwords.ts'
1313
export * from './utils/partners.ts'

api/src/sites/service.ts

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const getSiteByUrl = memoize(async (url: string) => {
1717
maxAge: 2000 // 2s
1818
})
1919

20+
const publicUrl = new URL(config.publicUrl)
21+
export const getSiteBaseUrl = (site: Site) => {
22+
return `${publicUrl.protocol}//${site.host}${site.path ?? ''}`
23+
}
24+
2025
export const getRedirectSite = async (req: Request, redirect: string) => {
2126
const currentSiteUrl = reqSiteUrl(req)
2227
const currentSite = await reqSite(req)

0 commit comments

Comments
 (0)