@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
378 lines (377 loc) • 17 kB
JavaScript
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;
}