UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

165 lines (164 loc) 7.99 kB
import * as validator from '@authenio/samlify-node-xmllint'; import { useEnv } from '@directus/env'; import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderError, isDirectusError, } from '@directus/errors'; import express, { Router } from 'express'; import * as samlify from 'samlify'; import { getAuthProvider } from '../../auth.js'; import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js'; import getDatabase from '../../database/index.js'; import emitter from '../../emitter.js'; import { useLogger } from '../../logger/index.js'; import { respond } from '../../middleware/respond.js'; import { AuthenticationService } from '../../services/authentication.js'; import asyncHandler from '../../utils/async-handler.js'; import { getConfigFromEnv } from '../../utils/get-config-from-env.js'; import { getSchema } from '../../utils/get-schema.js'; import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js'; import { LocalAuthDriver } from './local.js'; // Register the samlify schema validator samlify.setSchemaValidator(validator); export class SAMLAuthDriver extends LocalAuthDriver { sp; idp; config; constructor(options, config) { super(options, config); this.config = config; this.sp = samlify.ServiceProvider(getConfigFromEnv(`AUTH_${config['provider'].toUpperCase()}_SP`)); this.idp = samlify.IdentityProvider(getConfigFromEnv(`AUTH_${config['provider'].toUpperCase()}_IDP`)); } async fetchUserID(identifier) { const user = await this.knex .select('id') .from('directus_users') .whereRaw('LOWER(??) = ?', ['external_identifier', identifier.toLowerCase()]) .first(); return user?.id; } async getUserID(payload) { const logger = useLogger(); const { provider, emailKey, identifierKey, givenNameKey, familyNameKey, allowPublicRegistration } = this.config; const email = payload[emailKey ?? 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; const identifier = payload[identifierKey || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier']; if (!identifier) { logger.warn(`[SAML] Failed to find user identifier for provider "${provider}"`); throw new InvalidCredentialsError(); } const userID = await this.fetchUserID(identifier); if (userID) return userID; if (!allowPublicRegistration) { logger.warn(`[SAML] User doesn't exist, and public registration not allowed for provider "${provider}"`); throw new InvalidCredentialsError(); } const firstName = payload[givenNameKey ?? 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname']; const lastName = payload[familyNameKey ?? 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']; const userPayload = { provider, first_name: firstName, last_name: lastName, email: email, external_identifier: identifier.toLowerCase(), role: this.config['defaultRoleId'], }; const schema = await getSchema(); // Run hook so the end user has the chance to augment the // user that is about to be created const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, { identifier: identifier.toLowerCase(), provider: this.config['provider'], providerPayload: { ...payload } }, { database: getDatabase(), schema, accountability: null }); try { const usersService = this.getUsersService(schema); return await usersService.createOne(updatedUserPayload); } catch (error) { if (isDirectusError(error, ErrorCode.RecordNotUnique)) { logger.warn(error, '[SAML] Failed to register user. User not unique'); throw new InvalidProviderError(); } throw error; } } // There's no local checks to be done when the user is authenticated in the IdP async login(_user) { return; } } export function createSAMLAuthRouter(providerName) { const router = Router(); const env = useEnv(); router.get('/metadata', asyncHandler(async (_req, res) => { const { sp } = getAuthProvider(providerName); return res.header('Content-Type', 'text/xml').send(sp.getMetadata()); })); router.get('/', asyncHandler(async (req, res) => { const { sp, idp } = getAuthProvider(providerName); const { context: url } = sp.createLoginRequest(idp, 'redirect'); const parsedUrl = new URL(url); if (req.query['redirect']) { const redirect = req.query['redirect']; if (isLoginRedirectAllowed(redirect, providerName) === false) { throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` }); } parsedUrl.searchParams.append('RelayState', redirect); } return res.redirect(parsedUrl.toString()); })); router.post('/logout', asyncHandler(async (req, res) => { const { sp, idp } = getAuthProvider(providerName); const { context } = sp.createLogoutRequest(idp, 'redirect', req.body); const authService = new AuthenticationService({ accountability: req.accountability, schema: req.schema }); const sessionCookieName = env['SESSION_COOKIE_NAME']; if (req.cookies[sessionCookieName]) { await authService.logout(req.cookies[sessionCookieName]); res.clearCookie(sessionCookieName, SESSION_COOKIE_OPTIONS); } return res.redirect(context); })); router.post('/acs', express.urlencoded({ extended: false }), asyncHandler(async (req, res, next) => { const logger = useLogger(); const relayState = req.body?.RelayState; const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session'); if (relayState && isLoginRedirectAllowed(relayState, providerName) === false) { throw new InvalidPayloadError({ reason: `URL "${relayState}" can't be used to redirect after login` }); } try { const { sp, idp } = getAuthProvider(providerName); const { extract } = await sp.parseLoginResponse(idp, 'post', req); const authService = new AuthenticationService({ accountability: req.accountability, schema: req.schema }); const { accessToken, refreshToken, expires } = await authService.login(providerName, extract.attributes, { session: authMode === 'session', }); res.locals['payload'] = { data: { access_token: accessToken, refresh_token: refreshToken, expires, }, }; if (relayState) { if (authMode === 'session') { res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS); } else { res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS); } return res.redirect(relayState); } return next(); } catch (error) { if (relayState) { let reason = 'UNKNOWN_EXCEPTION'; if (isDirectusError(error)) { reason = error.code; } else { logger.warn(error, `[SAML] Unexpected error during SAML login`); } return res.redirect(`${relayState.split('?')[0]}?reason=${reason}`); } logger.warn(error, `[SAML] Unexpected error during SAML login`); throw error; } }), respond); return router; }