@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
343 lines (341 loc) • 13.2 kB
JavaScript
import async_handler_default from "../../utils/async-handler.js";
import { getConfigFromEnv } from "../../utils/get-config-from-env.js";
import { useLogger } from "../../logger/index.js";
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from "../../constants.js";
import database_default from "../../database/index.js";
import emitter_default from "../../emitter.js";
import { getSecret } from "../../utils/get-secret.js";
import { Url } from "../../utils/url.js";
import { createDefaultAccountability } from "../../permissions/utils/create-default-accountability.js";
import { verifyJWT } from "../../utils/jwt.js";
import { getSchema } from "../../utils/get-schema.js";
import { respond } from "../../middleware/respond.js";
import { getIPFromReq } from "../../utils/get-ip-from-req.js";
import { LocalAuthDriver } from "./local.js";
import { checkSsoEnabled } from "../utils/check-sso-enabled.js";
import { generateCallbackUrl } from "../utils/generate-callback-url.js";
import { resolveLoginRedirect } from "../utils/resolve-login-redirect.js";
import { getAuthProvider } from "../../auth.js";
import { AuthenticationService } from "../../services/authentication.js";
import { useEnv } from "@directus/env";
import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, ServiceUnavailableError, isDirectusError } from "@directus/errors";
import { parseJSON, toArray } from "@directus/utils";
import express, { Router } from "express";
import jwt from "jsonwebtoken";
import { flatten } from "flat";
import { Issuer, errors, generators } from "openid-client";
//#region src/auth/drivers/oauth2.ts
var OAuth2AuthDriver = class extends LocalAuthDriver {
client;
config;
roleMap;
constructor(options, config) {
super(options, config);
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"] });
}
this.config = additionalConfig;
this.roleMap = {};
const roleMapping = this.config["roleMapping"];
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();
}
if (roleMapping) this.roleMap = roleMapping;
const issuer = new Issuer({
authorization_endpoint: authorizeUrl,
token_endpoint: accessUrl,
userinfo_endpoint: profileUrl,
issuer: additionalConfig["provider"]
});
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,
response_types: ["code"],
...clientOptionsOverrides
});
}
generateCodeVerifier() {
return generators.codeVerifier();
}
generateAuthUrl(codeVerifier, prompt = false, callbackUrl) {
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" : void 0,
...paramsConfig,
code_challenge: codeChallenge,
code_challenge_method: plainCodeChallenge ? "plain" : "S256",
state: codeChallenge,
redirect_uri: callbackUrl
});
} catch (e) {
throw handleError(e);
}
}
async fetchUserId(identifier) {
return (await this.knex.select("id").from("directus_users").whereRaw("LOWER(??) = ?", ["external_identifier", identifier.toLowerCase()]).first())?.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(payload["callbackUrl"], {
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)) {
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.`);
userInfo = flatten(userInfo);
const { provider, emailKey, identifierKey, allowPublicRegistration, syncUserInfo } = this.config;
const email = userInfo[emailKey ?? "email"] ? String(userInfo[emailKey ?? "email"]) : void 0;
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,
external_identifier: identifier,
role,
auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token })
};
const userId = await this.fetchUserId(identifier);
if (userId) {
let emitPayload = { auth_data: userPayload.auth_data };
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$1 = await getSchema();
const updatedUserPayload$1 = await emitter_default.emitFilter(`auth.update`, emitPayload, {
identifier,
provider: this.config["provider"],
providerPayload: {
accessToken: tokenSet.access_token,
idToken: tokenSet.id_token,
userInfo
}
}, {
database: database_default(),
schema: schema$1,
accountability: null
});
if (Object.values(updatedUserPayload$1).some((value) => value !== void 0)) await this.getUsersService(schema$1).updateOne(userId, updatedUserPayload$1);
return userId;
}
if (!allowPublicRegistration) {
logger.warn(`[OAuth2] User doesn't exist, and public registration not allowed for provider "${provider}"`);
throw new InvalidCredentialsError();
}
const schema = await getSchema();
const updatedUserPayload = await emitter_default.emitFilter(`auth.create`, userPayload, {
identifier,
provider: this.config["provider"],
providerPayload: {
accessToken: tokenSet.access_token,
idToken: tokenSet.id_token,
userInfo
}
}, {
database: database_default(),
schema,
accountability: null
});
try {
await this.getUsersService(schema).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"]);
if (tokenSet.refresh_token) await this.getUsersService(await getSchema()).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") {
logger.warn(e, `[OAuth2] Invalid grant`);
return new InvalidTokenError();
}
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) {
logger.warn(e, `[OAuth2] Unknown RP error`);
return new InvalidCredentialsError();
}
logger.warn(e, `[OAuth2] Unknown error`);
return e;
};
function createOAuth2AuthRouter(providerName) {
const router = Router();
const env = useEnv();
router.use(checkSsoEnabled);
router.get("/", (req, res) => {
const provider = getAuthProvider(providerName);
const codeVerifier = provider.generateCodeVerifier();
const prompt = !!req.query["prompt"];
const otp = req.query["otp"];
let redirect = req.query["redirect"];
try {
redirect = resolveLoginRedirect(redirect, { provider: providerName });
} catch (e) {
useLogger().error(e);
throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
}
const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get("host")}`);
const token = jwt.sign({
verifier: codeVerifier,
redirect,
prompt,
otp,
callbackUrl
}, getSecret(), {
expiresIn: "5m",
issuer: "directus"
});
res.cookie(`oauth2.${providerName}`, token, {
httpOnly: true,
sameSite: "lax",
secure: Boolean(env[`AUTH_${providerName.toUpperCase()}_COOKIE_SECURE`])
});
return res.redirect(provider.generateAuthUrl(codeVerifier, prompt, callbackUrl));
}, respond);
router.post("/callback", express.urlencoded({ extended: false }), (req, res) => {
res.redirect(303, `./callback?${new URLSearchParams(req.body)}`);
}, respond);
router.get("/callback", async_handler_default(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, callbackUrl } = 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"],
callbackUrl
}, {
session: authMode === "session",
...otp ? { otp: String(otp) } : {}
});
} catch (error) {
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 {
if (verifyJWT(accessToken, getSecret())?.enforce_tfa === true) {
const url = new Url(env["PUBLIC_URL"]).addPath("admin", "tfa-setup");
if (redirect) {
url.setQuery("redirect", redirect);
url.setQuery("provider", providerName);
}
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;
}
//#endregion
export { OAuth2AuthDriver, createOAuth2AuthRouter };