UNPKG

@directus/api

Version:

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

378 lines (377 loc) 17 kB
import { useEnv } from '@directus/env'; import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, isDirectusError, ServiceUnavailableError, } from '@directus/errors'; import { parseJSON, toArray } from '@directus/utils'; import express, { Router } from 'express'; import { flatten } from 'flat'; import jwt from 'jsonwebtoken'; import { errors, generators, Issuer } from 'openid-client'; 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 { createDefaultAccountability } from '../../permissions/utils/create-default-accountability.js'; import { AuthenticationService } from '../../services/authentication.js'; import asyncHandler from '../../utils/async-handler.js'; import { getConfigFromEnv } from '../../utils/get-config-from-env.js'; import { getIPFromReq } from '../../utils/get-ip-from-req.js'; import { getSecret } from '../../utils/get-secret.js'; import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js'; import { verifyJWT } from '../../utils/jwt.js'; import { Url } from '../../utils/url.js'; import { LocalAuthDriver } from './local.js'; import { getSchema } from '../../utils/get-schema.js'; export class OAuth2AuthDriver extends LocalAuthDriver { client; redirectUrl; config; roleMap; constructor(options, config) { super(options, config); const env = useEnv(); const logger = useLogger(); const { authorizeUrl, accessUrl, profileUrl, clientId, clientSecret, ...additionalConfig } = config; if (!authorizeUrl || !accessUrl || !profileUrl || !clientId || !clientSecret || !additionalConfig['provider']) { logger.error('Invalid provider config'); throw new InvalidProviderConfigError({ provider: additionalConfig['provider'] }); } const redirectUrl = new Url(env['PUBLIC_URL']).addPath('auth', 'login', additionalConfig['provider'], 'callback'); this.redirectUrl = redirectUrl.toString(); this.config = additionalConfig; this.roleMap = {}; const roleMapping = this.config['roleMapping']; if (roleMapping) { this.roleMap = roleMapping; } // role mapping will fail on login if AUTH_<provider>_ROLE_MAPPING is an array instead of an object. // This happens if the 'json:' prefix is missing from the variable declaration. To save the user from exhaustive debugging, we'll try to fail early here. if (roleMapping instanceof Array) { logger.error("[OAuth2] Expected a JSON-Object as role mapping, got an Array instead. Make sure you declare the variable with 'json:' prefix."); throw new InvalidProviderError(); } const issuer = new Issuer({ authorization_endpoint: authorizeUrl, token_endpoint: accessUrl, userinfo_endpoint: profileUrl, issuer: additionalConfig['provider'], }); // extract client overrides/options excluding CLIENT_ID and CLIENT_SECRET as they are passed directly const clientOptionsOverrides = getConfigFromEnv(`AUTH_${config['provider'].toUpperCase()}_CLIENT_`, { omitKey: [ `AUTH_${config['provider'].toUpperCase()}_CLIENT_ID`, `AUTH_${config['provider'].toUpperCase()}_CLIENT_SECRET`, ], type: 'underscore', }); this.client = new issuer.Client({ client_id: clientId, client_secret: clientSecret, redirect_uris: [this.redirectUrl], response_types: ['code'], ...clientOptionsOverrides, }); } generateCodeVerifier() { return generators.codeVerifier(); } generateAuthUrl(codeVerifier, prompt = false) { const { plainCodeChallenge } = this.config; try { const codeChallenge = plainCodeChallenge ? codeVerifier : generators.codeChallenge(codeVerifier); const paramsConfig = typeof this.config['params'] === 'object' ? this.config['params'] : {}; return this.client.authorizationUrl({ scope: this.config['scope'] ?? 'email', access_type: 'offline', prompt: prompt ? 'consent' : undefined, ...paramsConfig, code_challenge: codeChallenge, code_challenge_method: plainCodeChallenge ? 'plain' : 'S256', // Some providers require state even with PKCE state: codeChallenge, }); } catch (e) { throw handleError(e); } } 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(); if (!payload['code'] || !payload['codeVerifier'] || !payload['state']) { logger.warn('[OAuth2] No code, codeVerifier or state in payload'); throw new InvalidCredentialsError(); } const { plainCodeChallenge } = this.config; let tokenSet; let userInfo; try { const codeChallenge = plainCodeChallenge ? payload['codeVerifier'] : generators.codeChallenge(payload['codeVerifier']); tokenSet = await this.client.oauthCallback(this.redirectUrl, { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge }); userInfo = await this.client.userinfo(tokenSet.access_token); } catch (e) { throw handleError(e); } let role = this.config['defaultRoleId']; const groupClaimName = this.config['groupClaimName'] ?? 'groups'; const groups = userInfo[groupClaimName] ? toArray(userInfo[groupClaimName]) : []; if (groups.length > 0) { for (const key in this.roleMap) { if (groups.includes(key)) { // Overwrite default role if user is member of a group specified in roleMap role = this.roleMap[key]; break; } } } else if (Object.keys(this.roleMap).length > 0) { logger.debug(`[OAuth2] Configured group claim with name "${groupClaimName}" does not exist or is empty.`); } // Flatten response to support dot indexes userInfo = flatten(userInfo); const { provider, emailKey, identifierKey, allowPublicRegistration, syncUserInfo } = this.config; const email = userInfo[emailKey ?? 'email'] ? String(userInfo[emailKey ?? 'email']) : undefined; // Fallback to email if explicit identifier not found const identifier = userInfo[identifierKey] ? String(userInfo[identifierKey]) : email; if (!identifier) { logger.warn(`[OAuth2] Failed to find user identifier for provider "${provider}"`); throw new InvalidCredentialsError(); } const userPayload = { provider, first_name: userInfo[this.config['firstNameKey']], last_name: userInfo[this.config['lastNameKey']], email: email, external_identifier: identifier, role: role, auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token }), }; const userId = await this.fetchUserId(identifier); if (userId) { // Run hook so the end user has the chance to augment the // user that is about to be updated let emitPayload = { auth_data: userPayload.auth_data, }; // Make sure a user's role gets updated if their oauth group or role mapping changes if (this.config['roleMapping']) { emitPayload['role'] = role; } if (syncUserInfo) { emitPayload = { ...emitPayload, first_name: userPayload.first_name, last_name: userPayload.last_name, email: userPayload.email, }; } const schema = await getSchema(); const updatedUserPayload = await emitter.emitFilter(`auth.update`, emitPayload, { identifier, provider: this.config['provider'], providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo }, }, { database: getDatabase(), schema, accountability: null }); // Update user to update refresh_token and other properties that might have changed if (Object.values(updatedUserPayload).some((value) => value !== undefined)) { const usersService = this.getUsersService(schema); await usersService.updateOne(userId, updatedUserPayload); } return userId; } // Is public registration allowed? if (!allowPublicRegistration) { logger.warn(`[OAuth2] User doesn't exist, and public registration not allowed for provider "${provider}"`); throw new InvalidCredentialsError(); } 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, provider: this.config['provider'], providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo }, }, { database: getDatabase(), schema, accountability: null }); try { const usersService = this.getUsersService(schema); await usersService.createOne(updatedUserPayload); } catch (e) { if (isDirectusError(e, ErrorCode.RecordNotUnique)) { logger.warn(e, '[OAuth2] Failed to register user. User not unique'); throw new InvalidProviderError(); } throw e; } return (await this.fetchUserId(identifier)); } async login(user) { return this.refresh(user); } async refresh(user) { const logger = useLogger(); let authData = user.auth_data; if (typeof authData === 'string') { try { authData = parseJSON(authData); } catch { logger.warn(`[OAuth2] Session data isn't valid JSON: ${authData}`); } } if (authData?.['refreshToken']) { try { const tokenSet = await this.client.refresh(authData['refreshToken']); // Update user refreshToken if provided if (tokenSet.refresh_token) { const usersService = this.getUsersService(await getSchema()); await usersService.updateOne(user.id, { auth_data: JSON.stringify({ refreshToken: tokenSet.refresh_token }), }); } } catch (e) { throw handleError(e); } } } } const handleError = (e) => { const logger = useLogger(); if (e instanceof errors.OPError) { if (e.error === 'invalid_grant') { // Invalid token logger.warn(e, `[OAuth2] Invalid grant`); return new InvalidTokenError(); } // Server response error logger.warn(e, `[OAuth2] Unknown OP error`); return new ServiceUnavailableError({ service: 'oauth2', reason: `Service returned unexpected response: ${e.error_description}`, }); } else if (e instanceof errors.RPError) { // Internal client error logger.warn(e, `[OAuth2] Unknown RP error`); return new InvalidCredentialsError(); } logger.warn(e, `[OAuth2] Unknown error`); return e; }; export function createOAuth2AuthRouter(providerName) { const router = Router(); const env = useEnv(); router.get('/', (req, res) => { const provider = getAuthProvider(providerName); const codeVerifier = provider.generateCodeVerifier(); const prompt = !!req.query['prompt']; const redirect = req.query['redirect']; const otp = req.query['otp']; if (isLoginRedirectAllowed(redirect, providerName) === false) { throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` }); } const token = jwt.sign({ verifier: codeVerifier, redirect, prompt, otp }, getSecret(), { expiresIn: '5m', issuer: 'directus', }); res.cookie(`oauth2.${providerName}`, token, { httpOnly: true, sameSite: 'lax', }); return res.redirect(provider.generateAuthUrl(codeVerifier, prompt)); }, respond); router.post('/callback', express.urlencoded({ extended: false }), (req, res) => { res.redirect(303, `./callback?${new URLSearchParams(req.body)}`); }, respond); router.get('/callback', asyncHandler(async (req, res, next) => { const logger = useLogger(); let tokenData; try { tokenData = verifyJWT(req.cookies[`oauth2.${providerName}`], getSecret()); } catch (e) { logger.warn(e, `[OAuth2] Couldn't verify OAuth2 cookie`); throw new InvalidCredentialsError(); } const { verifier, prompt, otp } = tokenData; let { redirect } = tokenData; const accountability = createDefaultAccountability({ ip: getIPFromReq(req), }); const userAgent = req.get('user-agent')?.substring(0, 1024); if (userAgent) accountability.userAgent = userAgent; const origin = req.get('origin'); if (origin) accountability.origin = origin; const authenticationService = new AuthenticationService({ accountability, schema: req.schema, }); const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session'); let authResponse; try { res.clearCookie(`oauth2.${providerName}`); authResponse = await authenticationService.login(providerName, { code: req.query['code'], codeVerifier: verifier, state: req.query['state'], }, { session: authMode === 'session', ...(otp ? { otp: String(otp) } : {}) }); } catch (error) { // Prompt user for a new refresh_token if invalidated if (isDirectusError(error, ErrorCode.InvalidToken) && !prompt) { return res.redirect(`./?${redirect ? `redirect=${redirect}&` : ''}prompt=true`); } if (redirect) { let reason = 'UNKNOWN_EXCEPTION'; if (isDirectusError(error)) { reason = error.code; } else { logger.warn(error, `[OAuth2] Unexpected error during OAuth2 login`); } return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`); } logger.warn(error, `[OAuth2] Unexpected error during OAuth2 login`); throw error; } const { accessToken, refreshToken, expires } = authResponse; try { const claims = verifyJWT(accessToken, getSecret()); if (claims?.enforce_tfa === true) { const url = new Url(env['PUBLIC_URL']).addPath('admin', 'tfa-setup'); if (redirect) url.setQuery('redirect', redirect); redirect = url.toString(); } } catch (e) { logger.warn(e, `[OAuth2] Unexpected error during OAuth2 login`); } if (redirect) { 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(redirect); } res.locals['payload'] = { data: { access_token: accessToken, refresh_token: refreshToken, expires }, }; next(); }), respond); return router; }