Skip to content

Commit

Permalink
feat: work on saml config per site
Browse files Browse the repository at this point in the history
  • Loading branch information
albanm committed Feb 3, 2025
1 parent 7605847 commit 848c6ea
Show file tree
Hide file tree
Showing 14 changed files with 143 additions and 57 deletions.
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
##########################
FROM node:22.11.0-alpine3.20 AS base
FROM node:22.13.1-alpine3.21 AS base

RUN npm install -g npm@10.9.1
RUN npm install -g npm@11.1.0

WORKDIR /app
ENV NODE_ENV=production
Expand Down Expand Up @@ -59,7 +59,6 @@ RUN npm -w ui run build
##########################
FROM installer AS api-installer

# remove other workspaces and reinstall, otherwise we can get rig have some peer dependencies from other workspaces
RUN npm ci -w api --prefer-offline --omit=dev --omit=optional --omit=peer --no-audit --no-fund && \
npx clean-modules --yes "!ramda/src/test.js"
RUN mkdir -p /app/api/node_modules
Expand All @@ -77,8 +76,13 @@ COPY --from=types /app/api/config api/config
COPY --from=api-installer /app/api/node_modules api/node_modules
COPY --from=ui /app/ui/dist ui/dist
ADD package.json README.md LICENSE BUILD.json* ./

EXPOSE 8080
EXPOSE 9090

USER node
WORKDIR /app/api

ENV DEBUG upgrade*

CMD ["node", "--max-http-header-size", "65536", "--experimental-strip-types", "index.ts"]
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"#i18n": "./i18n/index.ts"
},
"dependencies": {
"@data-fair/lib-express": "^1.10.1",
"@data-fair/lib-express": "^1.12.6",
"@data-fair/lib-node": "^2.3.1",
"@data-fair/lib-utils": "^1.2.0",
"@sd/shared": "*",
Expand Down
12 changes: 11 additions & 1 deletion api/src/auth/providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSiteByUrl } from '#services'
import { oauthGlobalProviders, getOidcProviderId, saml2GlobalProviders, getSamlConfigId, getSiteByUrl } from '#services'
import type { Site, PublicAuthProvider } from '#types'
import _slug from 'slugify'

Expand Down Expand Up @@ -44,6 +44,16 @@ export const publicSiteProviders = async (site: Site) => {
redirectMode: p.redirectMode
})
}
if (p.type === 'saml2') {
providers.push({
type: p.type,
id: getSamlConfigId(p),
title: p.title as string,
color: p.color,
img: p.img,
redirectMode: p.redirectMode
})
}
if (p.type === 'otherSite') {
const otherSiteUrl = (p.site.startsWith('http://') || p.site.startsWith('https://')) ? p.site : `https://${p.site}`
const otherSite = await getSiteByUrl(otherSiteUrl)
Expand Down
13 changes: 7 additions & 6 deletions api/src/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import bodyParser from 'body-parser'
import { nanoid } from 'nanoid'
import Cookies from 'cookies'
import Debug from 'debug'
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'
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'
import type { SdStorage } from '../storages/interface.ts'
import type { ActionPayload, ServerSession, User, UserWritable } from '#types'
import eventsLog, { type EventLogContext } from '@data-fair/lib-express/events-log.js'
Expand Down Expand Up @@ -809,17 +809,17 @@ router.post('/oauth-logout', oauthLogoutCallback)
const debugSAML = Debug('saml')

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

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

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

// TODO: apply nameid parameter ? { nameid: req.query.email }
Expand All @@ -845,6 +845,7 @@ router.get('/saml2/:providerId/login', async (req, res) => {
if (typeof req.query.email === 'string') parsedURL.searchParams.append('login_hint', req.query.email)
debugSAML('redirect', parsedURL.href)
eventsLog.info('sd.auth.saml.redirect', 'a user was redirected to a saml provider', logContext)
console.log('REDIRECT SAMLRequest', parsedURL.searchParams.get('SAMLRequest'))
res.redirect(parsedURL.href)
})

Expand All @@ -855,7 +856,7 @@ router.post('/saml2-assert', async (req, res) => {
const site = await reqSite(req)
const storage = storages.globalStorage
const providers = saml2GlobalProviders()
const sp = saml2ServiceProvider()
const sp = await saml2ServiceProvider(await reqSite(req))

let provider
const referer = (req.headers.referer || req.headers.referrer) as string | undefined
Expand Down
104 changes: 76 additions & 28 deletions api/src/saml2/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// useful tutorial
// https://medium.com/disney-streaming/setup-a-single-sign-on-saml-test-environment-with-docker-and-nodejs-c53fc1a984c9

import type { Request } from 'express'
import { readFile, access, constants } from 'node:fs/promises'
import type { SAML2 } from '../../config/type/index.ts'
import config from '#config'
Expand All @@ -11,6 +12,8 @@ import mongo from '#mongo'
import { decipher, cipher } from '../utils/cipher.ts'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { getSiteBaseUrl, reqSite } from '#services'
import { type Site } from '#types'

const execAsync = promisify(exec)
const debug = Debug('saml')
Expand All @@ -29,6 +32,27 @@ samlify.setSchemaValidator({
}
})

export const getSamlProviderById = async (req: Request, id: string): Promise<PreparedSaml2Provider | undefined> => {
const site = await reqSite(req)
if (!site) {
return saml2GlobalProviders().find(p => p.id === id)
} else {
const providerInfo = site.authProviders?.find(p => p.type === 'saml2' && getSamlConfigId(p) === id) as SAML2
const idp = samlify.IdentityProvider(providerInfo)
return {
id,
...providerInfo,
idp
}
}
}

export const getSamlConfigId = (providerConfig: SAML2) => {
const idp = samlify.IdentityProvider(providerConfig)
if (!idp.entityMeta.meta.entityID) throw new Error('missing entityID in saml IDP metadata')
return getSamlProviderId(idp.entityMeta.meta.entityID)
}

export const getSamlProviderId = (url: string) => {
return slug(new URL(url).host, { lower: true, strict: true })
}
Expand Down Expand Up @@ -56,8 +80,16 @@ const readDeprecatedCertificates = async (): Promise<undefined | Certificates> =
}
}

const readCertificates = async (): Promise<undefined | Certificates> => {
const secret = await mongo.secrets.findOne({ _id: 'saml-certificates' })
const getSiteCertKey = (site?: Site) => {
if (!site) return 'saml-certificates'
let key = 'saml-certificates-' + slug(site.host)
if (site.path) key += `--${slug(site.path)}`
return key
}

const readCertificates = async (site?: Site): Promise<undefined | Certificates> => {
const key = getSiteCertKey(site)
const secret = await mongo.secrets.findOne({ _id: key })
if (secret) {
const certificates = secret.data
certificates.signing.privateKey = decipher(certificates.signing.privateKey)
Expand All @@ -66,52 +98,47 @@ const readCertificates = async (): Promise<undefined | Certificates> => {
}
}

const writeCertificates = async (certificates: Certificates) => {
const writeCertificates = async (certificates: Certificates, site?: Site) => {
const key = getSiteCertKey(site)
const storedCertificates = { signing: { ...certificates.signing }, encrypt: { ...certificates.encrypt } } as any
storedCertificates.signing.privateKey = cipher(certificates.signing.privateKey)
storedCertificates.encrypt.privateKey = cipher(certificates.encrypt.privateKey)
await mongo.secrets.insertOne({ _id: 'saml-certificates', data: storedCertificates })
await mongo.secrets.insertOne({ _id: key, data: storedCertificates })
}

const _globalProviders: PreparedSaml2Provider[] = []
let _sp: samlify.ServiceProviderInstance | undefined
export const saml2ServiceProvider = () => {
if (!_sp) throw new Error('Global Saml 2 providers ware not initialized')
export const saml2ServiceProvider = async (site?: Site) => {
if (!_sp) throw new Error('Global Saml 2 provider was not initialized')
if (site) return await initServiceProvider(site)
return _sp
}
export const saml2GlobalProviders = () => {
if (!_sp) throw new Error('Global Saml 2 providers ware not initialized')
if (!_sp) throw new Error('Global Saml 2 provider was not initialized')
return _globalProviders
}

export const init = async () => {
let certificates = await readCertificates()
const initCertificates = async (site?: Site) => {
let certificates = await readCertificates(site)
if (!certificates) {
console.log('Initializing SAML certificates')
certificates = await readDeprecatedCertificates()
if (certificates) {
console.log('Migrating SAML certificates from filesystem to database')
} else {
if (!site) {
certificates = await readDeprecatedCertificates()
if (certificates) {
console.log('Migrating SAML certificates from filesystem to database')
}
}
if (!certificates) {
console.log('Generating new SAML certificates')
certificates = { signing: await createCert(), encrypt: await createCert() }
}
await writeCertificates(certificates)
await writeCertificates(certificates, site)
}
return certificates
}

const assertionConsumerService = [{
Binding: samlify.Constants.namespace.binding.post,
Location: `${config.publicUrl}/api/auth/saml2-assert`
}]
debug('config service provider')
_sp = samlify.ServiceProvider({
entityID: `${config.publicUrl}/api/auth/saml2-metadata.xml`,
assertionConsumerService,
signingCert: certificates.signing.cert,
privateKey: certificates.signing.privateKey,
encryptCert: certificates.encrypt.cert,
encPrivateKey: certificates.encrypt.privateKey,
...config.saml2.sp
})
export const init = async () => {
_sp = await initServiceProvider()

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

export const initServiceProvider = async (site?: Site) => {
const certificates = await initCertificates(site)
const url = site ? getSiteBaseUrl(site) : config.publicUrl
const assertionConsumerService = [{
Binding: samlify.Constants.namespace.binding.post,
Location: `${url}/api/auth/saml2-assert`
}]
debug('config service provider')
return samlify.ServiceProvider({
entityID: `${url}/api/auth/saml2-metadata.xml`,
assertionConsumerService,
signingCert: certificates.signing.cert,
privateKey: certificates.signing.privateKey,
encryptCert: certificates.encrypt.cert,
encPrivateKey: certificates.encrypt.privateKey,
// @ts-ignore if we use a boolean the attribute is set as empty in the xml output, and some IDP don't like that
allowCreate: 'false',
...config.saml2.sp
})
}

const createCert = async () => {
const subject = `/C=FR/CN=${new URL(config.publicUrl).hostname}`
const privateKey = (await execAsync('openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048')).stdout
Expand Down
4 changes: 2 additions & 2 deletions api/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export * from './limits/service.ts'
export * from './mails/service.ts'
export { initOidcProvider, oauthGlobalProviders, getOidcProviderId, getOAuthProviderById, getOAuthProviderByState } from './oauth/service.ts'
export * from './oauth-tokens/service.ts'
export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId } from './saml2/service.ts'
export { reqSite, getSiteByUrl, getRedirectSite } from './sites/service.ts'
export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId, getSamlConfigId, getSamlProviderById } from './saml2/service.ts'
export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl } from './sites/service.ts'
export * from './tokens/service.ts'
export * from './utils/passwords.ts'
export * from './utils/partners.ts'
Expand Down
5 changes: 5 additions & 0 deletions api/src/sites/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const getSiteByUrl = memoize(async (url: string) => {
maxAge: 2000 // 2s
})

const publicUrl = new URL(config.publicUrl)
export const getSiteBaseUrl = (site: Site) => {
return `${publicUrl.protocol}://${site.host}${site.path ?? ''}`
}

export const getRedirectSite = async (req: Request, redirect: string) => {
const currentSiteUrl = reqSiteUrl(req)
const currentSite = await reqSite(req)
Expand Down
5 changes: 3 additions & 2 deletions api/types/site/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default {
metadata: {
type: 'string',
title: 'XML metadata',
layout: 'textarea'
layout: { comp: 'textarea', slots: { before: { name: 'saml-help' } } }
},
color: {
type: 'string',
Expand Down Expand Up @@ -247,7 +247,8 @@ export default {
discovery: {
type: 'string',
title: 'URL de découverte',
description: 'probablement de la forme http://mon-fournisseur/.well-known/openid-configuration'
description: 'probablement de la forme http://mon-fournisseur/.well-known/openid-configuration',
layout: { slots: { before: { name: 'oidc-help' } } }
},
client: {
type: 'object',
Expand Down
1 change: 1 addition & 0 deletions dev/resources/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ http {
}

# another one to simulate multi-site with a path prefix
# to test this one edit devSitePath in vite.config.ts
server {
listen 6099;
server_name _;
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ services:
image: kristophjunge/test-saml-idp:1.15
network_mode: host
environment:
- SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:5689/simple-directory/api/auth/saml2-metadata.xml
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:5689/simple-directory/api/auth/saml2-assert
- SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:5689/simple-directory/api/auth/saml2-logout
- SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:5989/simple-directory/api/auth/saml2-metadata.xml
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:5989/simple-directory/api/auth/saml2-assert
- SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:5989/simple-directory/api/auth/saml2-logout

# WARNING: does not work on a recent chrome, this provider tries to use a cookie with samesite=none option and this is not permitted without https
# list of users : [email protected]
Expand Down
Loading

0 comments on commit 848c6ea

Please sign in to comment.