import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import * as jose from 'jose'; import * as Redis from 'ioredis'; import * as saml from 'samlify'; import * as validator from '@authenio/samlify-node-xmllint'; import fastifyView from '@fastify/view'; import fastifyCors from '@fastify/cors'; import fastifyFormbody from '@fastify/formbody'; import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced'; import pug from 'pug'; import xmlbuilder from 'xmlbuilder'; import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import Logger from '@/logger.js'; import type { MiSingleSignOnServiceProvider, SingleSignOnServiceProviderRepository, UserProfilesRepository, UsersRepository, } from '@/models/_.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { LoggerService } from '@/core/LoggerService.js'; import { RoleService } from '@/core/RoleService.js'; import type { MiLocalUser } from '@/models/User.js'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import { normalizeEmailAddress } from '@/misc/normalize-email-address.js'; import type { FastifyInstance } from 'fastify'; @Injectable() export class SAMLIdentifyProviderService { #logger: Logger; constructor( @Inject(DI.config) private config: Config, @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.singleSignOnServiceProviderRepository) private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, private roleService: RoleService, private cacheService: CacheService, private loggerService: LoggerService, ) { this.#logger = this.loggerService.getLogger('sso:saml'); saml.setSchemaValidator(validator); } public async createIdPMetadataXml( provider: MiSingleSignOnServiceProvider, ): Promise { const tenYearsLaterTime = new Date(provider.createdAt.getTime()); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); const tenYearsLater = tenYearsLaterTime.toISOString(); const nodes = { 'md:EntityDescriptor': { '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', '@entityID': `${this.config.url}/sso/saml/${provider.id}/metadata`, '@validUntil': tenYearsLater, 'md:IDPSSODescriptor': { '@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned, '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', 'md:KeyDescriptor': { '@use': 'signing', 'ds:KeyInfo': { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, }, 'md:NameIDFormat': [ { '#text': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' }, { '#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' }, ], 'md:SingleSignOnService': [ { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', '@Location': `${this.config.url}/sso/saml/${provider.id}`, }, { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', '@Location': `${this.config.url}/sso/saml/${provider.id}`, }, ], }, }, }; return xmlbuilder .create(nodes, { encoding: 'UTF-8', standalone: false }) .end({ pretty: true }); } public async createSPMetadataXml( provider: MiSingleSignOnServiceProvider, ): Promise { const tenYearsLaterTime = new Date(provider.createdAt.getTime()); tenYearsLaterTime.setFullYear(tenYearsLaterTime.getFullYear() + 10); const tenYearsLater = tenYearsLaterTime.toISOString(); const keyDescriptor: unknown[] = [ { '@use': 'signing', 'ds:KeyInfo': { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, }, ]; if (provider.cipherAlgorithm) { keyDescriptor.push({ '@use': 'encryption', 'ds:KeyInfo': { '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { '#text': provider.publicKey.replace(/-----(?:BEGIN|END) CERTIFICATE-----|\s/g, ''), }, }, }, 'md:EncryptionMethod': [ { '@Algorithm': `http://www.w3.org/2001/04/xmlenc#${provider.cipherAlgorithm}` }, ], }); } const nodes = { 'md:EntityDescriptor': { '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', '@entityID': provider.issuer, '@validUntil': tenYearsLater, 'md:SPSSODescriptor': { '@AuthnRequestsSigned': provider.wantAuthnRequestsSigned, '@WantAssertionsSigned': provider.wantAssertionsSigned, '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', 'md:KeyDescriptor': keyDescriptor, 'md:NameIDFormat': [ { '#text': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' }, { '#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' }, ], 'md:AssertionConsumerService': { '@isDefault': 'true', '@index': 0, '@Binding': provider.binding === 'post' ? 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' : 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', '@Location': provider.acsUrl, }, }, }, }; return xmlbuilder .create(nodes, { encoding: 'UTF-8', standalone: false }) .end({ pretty: true }); } /** * @desc Alternative to lodash.get * @reference https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get * @param obj * @param path * @param defaultValue */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private get(obj: any, path: string, defaultValue: unknown) { return path .split('.') .reduce((a, c) => (a?.[c] ? a[c] : defaultValue || null), obj); } @bindThis public async createServer(fastify: FastifyInstance): Promise { fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } }); fastify.register(fastifyFormbody); fastify.register(fastifyCors); fastify.register(fastifyView, { root: fileURLToPath(new URL('../web/views', import.meta.url)), engine: { pug }, defaultContext: { version: this.config.version, config: this.config, }, }); fastify.all<{ Params: { serviceId: string }; Querystring?: { SAMLRequest?: string; RelayState?: string }; Body?: { SAMLRequest?: string; RelayState?: string }; }>('/:serviceId', async (request, reply) => { const serviceId = request.params.serviceId; const binding = request.query?.SAMLRequest ? 'redirect' : 'post'; const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest; const relayState = request.query?.RelayState ?? request.body?.RelayState; const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml', privateKey: Not(IsNull()) }); if (!ssoServiceProvider) { reply.status(403).send({ error: { message: 'Invalid SSO Service Provider id', code: 'INVALID_SSO_SP_ID', id: 'e2893d7e-df6f-44cf-8717-42234b8ac0ce', kind: 'client', }, }); return; } if (!samlRequest) { reply.status(400).send({ error: { message: 'No SAMLRequest', code: 'NO_SAML_REQUEST', id: 'c58bc7e3-f92e-4879-a6a9-7258a13bc491', kind: 'client', }, }); return; } const idp = saml.IdentityProvider({ metadata: await this.createIdPMetadataXml(ssoServiceProvider), privateKey: await jose .importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}')) .then(k => jose.exportPKCS8(k as jose.KeyLike)), }); const sp = saml.ServiceProvider({ metadata: await this.createSPMetadataXml(ssoServiceProvider), }); try { const parsed = await idp.parseLoginRequest(sp, binding, { query: request.query, body: request.body }); this.#logger.info('Parsed SAML request', { saml: parsed }); const transactionId = randomUUID(); await this.redisClient.set( `sso:saml:transaction:${transactionId}`, JSON.stringify({ serviceId: serviceId, flowResult: parsed, relayState: relayState, }), 'EX', 60 * 5, ); this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`); reply.header('Cache-Control', 'no-store'); return await reply.view('sso', { transactionId: transactionId, serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer, kind: 'saml', }); } catch (err) { this.#logger.error('Failed to parse SAML request', { error: err }); const traceableError = err as Error & {code?: string}; if (traceableError.code) { reply.status(500).send({ error: { message: traceableError.message, code: traceableError.code, id: 'a8aa8e69-a4c3-4148-8efe-fe3e8cb89684', kind: 'client', }, }); return; } reply.status(400).send({ error: { message: 'Invalid SAML Request', code: 'INVALID_SAML_REQUEST', id: '874b9cc2-71cb-4000-95c7-449391ee9861', kind: 'client', }, }); } }); fastify.get<{ Params: { serviceId: string } }>( '/:serviceId/metadata', async (request, reply) => { const serviceId = request.params.serviceId; const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' }); if (!ssoServiceProvider) { reply.status(403).send({ error: { message: 'Invalid SSO Service Provider id', code: 'INVALID_SSO_SP_ID', id: '8a6d72e1-3530-4ec0-9d4d-b105fdbb8a2d', kind: 'client', }, }); return; } reply.header('Content-Type', 'application/xml'); reply.send(await this.createIdPMetadataXml(ssoServiceProvider)); }, ); fastify.post<{ Body: { transaction_id: string; login_token: string; }; }>('/authorize', async (request, reply) => { const transactionId = request.body.transaction_id; const token = request.body.login_token; const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`); if (!transaction) { reply.status(403).send({ error: { message: 'Invalid transaction id', code: 'INVALID_TRANSACTION_ID', id: 'cca6ea16-5f04-4d9e-9ef5-8a99bdef3a92', kind: 'client', }, }); return; } const { serviceId, flowResult, relayState } = JSON.parse(transaction); const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' }); if (!ssoServiceProvider) { reply.status(403).send({ error: { message: 'Invalid SSO Service Provider id', code: 'INVALID_SSO_SP_ID', id: 'f644adfe-019a-478c-b5a9-897a2556f2b2', kind: 'client', }, }); return; } if (!token) { reply.status(401).send({ error: { message: 'No login token', code: 'NO_LOGIN_TOKEN', id: 'cd96295e-0370-433d-a3de-421de4536b7f', kind: 'client', }, }); return; } const user = await this.cacheService.localUserByNativeTokenCache.fetch( token, () => this.usersRepository.findOneBy({ token }) as Promise, ); if (!user) { reply.status(403).send({ error: { message: 'Invalid login token', code: 'INVALID_LOGIN_TOKEN', id: 'a002a4ed-0024-460f-8015-cc5e7c6cd0a7', kind: 'client', }, }); return; } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const isAdministrator = await this.roleService.isAdministrator(user); const isModerator = await this.roleService.isModerator(user); const roles = await this.roleService.getUserRoles(user.id); try { const idp = saml.IdentityProvider({ metadata: await this.createIdPMetadataXml(ssoServiceProvider), privateKey: await jose .importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}')) .then(k => jose.exportPKCS8(k as jose.KeyLike)), loginResponseTemplate: { context: 'ignored' }, }); const sp = saml.ServiceProvider({ metadata: await this.createSPMetadataXml(ssoServiceProvider), }); const loginResponse = await idp.createLoginResponse( sp, flowResult, ssoServiceProvider.binding, {}, () => { const id = idp.entitySetting.generateID?.() ?? randomUUID(); const assertionId = idp.entitySetting.generateID?.() ?? randomUUID(); const nowTime = new Date(); const fiveMinutesLaterTime = new Date(nowTime.getTime()); fiveMinutesLaterTime.setMinutes(fiveMinutesLaterTime.getMinutes() + 5); const now = nowTime.toISOString(); const fiveMinutesLater = fiveMinutesLaterTime.toISOString(); const nodes = { 'samlp:Response': { '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '@ID': id, '@Version': '2.0', '@IssueInstant': now, '@Destination': ssoServiceProvider.acsUrl, '@InResponseTo': this.get(flowResult, 'extract.request.id', ''), 'saml:Issuer': { '#text': `${this.config.url}/sso/saml/${ssoServiceProvider.id}/metadata`, }, 'samlp:Status': { 'samlp:StatusCode': { '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success', }, }, 'saml:Assertion': { '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', '@ID': assertionId, '@Version': '2.0', '@IssueInstant': now, 'saml:Issuer': { '#text': `${this.config.url}/sso/saml/${ssoServiceProvider.id}/metadata`, }, 'saml:Subject': { 'saml:NameID': profile.emailVerified ? { '@Format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', '#text': normalizeEmailAddress(profile.email) } : { '@Format': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', '#text': user.id }, 'saml:SubjectConfirmation': { '@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer', 'saml:SubjectConfirmationData': { '@InResponseTo': this.get(flowResult, 'extract.request.id', ''), '@NotOnOrAfter': fiveMinutesLater, '@Recipient': ssoServiceProvider.acsUrl, }, }, }, 'saml:Conditions': { '@NotBefore': now, '@NotOnOrAfter': fiveMinutesLater, 'saml:AudienceRestriction': { 'saml:Audience': [ { '#text': ssoServiceProvider.issuer }, ...ssoServiceProvider.audience.map((audience) => ({ '#text': audience, })), ], }, }, 'saml:AuthnStatement': { '@AuthnInstant': now, '@SessionIndex': assertionId, '@SessionNotOnOrAfter': fiveMinutesLater, 'saml:AuthnContext': { 'saml:AuthnContextClassRef': { '#text': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', }, }, }, 'saml:AttributeStatement': { 'saml:Attribute': [ { '@Name': 'identityprovider', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': this.config.url, }, }, { '@Name': 'uid', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': user.id, }, }, { '@Name': 'displayname', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': user.name ?? user.username, }, }, { '@Name': 'name', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': user.username, }, }, { '@Name': 'preferred_username', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': user.username, }, }, { '@Name': 'profile', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': `${this.config.url}/@${user.username}`, }, }, ...(user.avatarUrl ? [{ '@Name': 'picture', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': user.avatarUrl, }, }] : []), ...(profile.emailVerified ? [{ '@Name': 'email', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:string', '#text': normalizeEmailAddress(profile.email), }, }] : []), { '@Name': 'email_verified', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:boolean', '#text': profile.emailVerified, }, }, { '@Name': 'mfa_enabled', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:boolean', '#text': profile.twoFactorEnabled, }, }, { '@Name': 'updated_at', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:integer', '#text': Math.floor((user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000), }, }, { '@Name': 'admin', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:boolean', '#text': isAdministrator, }, }, { '@Name': 'moderator', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': { '@xsi:type': 'xs:boolean', '#text': isModerator, }, }, { '@Name': 'roles', '@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', 'saml:AttributeValue': [ ...roles .filter((r) => r.isPublic) .map((r) => ({ '@xsi:type': 'xs:string', '#text': r.id, })), ], }, ], }, }, }, }; return { id, context: xmlbuilder .create(nodes, { encoding: 'UTF-8', standalone: false }) .end({ pretty: false }), }; }, undefined, relayState, ); this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`); reply.header('Cache-Control', 'no-store'); switch (ssoServiceProvider.binding) { case 'post': return reply .status(200) .send({ binding: 'post', action: ssoServiceProvider.acsUrl, context: { SAMLResponse: loginResponse.context, RelayState: relayState ?? undefined, }, }); case 'redirect': return reply .status(200) .send({ binding: 'redirect', action: loginResponse.context, }); } } catch (err) { this.#logger.error('Failed to create SAML response', { error: err }); const traceableError = err as Error & { code?: string }; if (traceableError.code) { reply.status(500).send({ error: { message: traceableError.message, code: traceableError.code, id: 'a743ff78-8636-4b69-a54f-e3b395564f79', kind: 'server', }, }); return; } reply.status(500).send({ error: { message: 'Internal server error', code: 'INTERNAL_SERVER_ERROR', id: 'b83b7afd-adfc-4baf-8659-34623d639170', kind: 'server', }, }); return; } finally { await this.redisClient.del(`sso:saml:transaction:${transactionId}`); } }); } }