UNPKG

@directus/api

Version:

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

394 lines (392 loc) 15.4 kB
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, custom, errors, generators } from "openid-client"; //#region src/auth/drivers/openid.ts var OpenIDAuthDriver = class extends LocalAuthDriver { client; config; roleMap; constructor(options, config) { super(options, config); const logger = useLogger(); const { issuerUrl, clientId, clientSecret, clientPrivateKeys, clientTokenEndpointAuthMethod, provider, issuerDiscoveryMustSucceed } = config; if (!issuerUrl || !clientId || !(clientSecret || clientTokenEndpointAuthMethod === "private_key_jwt" && clientPrivateKeys) || !provider) { logger.error("Invalid provider config"); throw new InvalidProviderConfigError({ provider }); } this.config = config; this.roleMap = {}; const roleMapping = this.config["roleMapping"]; if (roleMapping) this.roleMap = roleMapping; if (roleMapping instanceof Array) { logger.error("[OpenID] Expected a JSON-Object as role mapping, got an Array instead. Make sure you declare the variable with 'json:' prefix."); throw new InvalidProviderError(); } this.client = null; this.getClient().catch((e) => { logger.error(e, "[OpenID] Failed to fetch provider config"); if (issuerDiscoveryMustSucceed !== false) { logger.error(`AUTH_${provider.toUpperCase()}_ISSUER_DISCOVERY_MUST_SUCCEED is enabled and discovery failed, exiting`); process.exit(1); } }); } async getClient() { if (this.client) return this.client; const logger = useLogger(); const { issuerUrl, clientId, clientSecret, clientPrivateKeys, clientTokenEndpointAuthMethod, provider } = this.config; const isPrivateKeyJwtAuthMethod = clientTokenEndpointAuthMethod === "private_key_jwt"; const clientHttpOptions = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_CLIENT_HTTP_`); if (clientHttpOptions) Issuer[custom.http_options] = (_, options) => { return { ...options, ...clientHttpOptions }; }; const issuer = await Issuer.discover(issuerUrl); if (!issuer.metadata["response_types_supported"]?.includes("code")) { logger.error("OpenID provider does not support required code flow"); throw new InvalidProviderConfigError({ provider }); } const clientOptionsOverrides = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_CLIENT_`, { omitKey: [ `AUTH_${provider.toUpperCase()}_CLIENT_ID`, `AUTH_${provider.toUpperCase()}_CLIENT_SECRET`, `AUTH_${provider.toUpperCase()}_CLIENT_PRIVATE_KEYS` ], omitPrefix: [`AUTH_${provider.toUpperCase()}_CLIENT_HTTP_`], type: "underscore" }); const client = new issuer.Client({ client_id: clientId, ...!isPrivateKeyJwtAuthMethod && { client_secret: clientSecret }, response_types: ["code"], ...clientOptionsOverrides }, isPrivateKeyJwtAuthMethod ? { keys: clientPrivateKeys } : void 0); if (clientHttpOptions) client[custom.http_options] = (_, options) => { return { ...options, ...clientHttpOptions }; }; this.client = client; return client; } generateCodeVerifier() { return generators.codeVerifier(); } async generateAuthUrl(codeVerifier, prompt = false, callbackUrl) { const { plainCodeChallenge } = this.config; try { const client = await this.getClient(); const codeChallenge = plainCodeChallenge ? codeVerifier : generators.codeChallenge(codeVerifier); const paramsConfig = typeof this.config["params"] === "object" ? this.config["params"] : {}; return client.authorizationUrl({ scope: this.config["scope"] ?? "openid profile email", access_type: "offline", prompt: prompt ? "consent" : void 0, ...paramsConfig, code_challenge: codeChallenge, code_challenge_method: plainCodeChallenge ? "plain" : "S256", state: codeChallenge, nonce: 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("[OpenID] No code, codeVerifier or state in payload"); throw new InvalidCredentialsError(); } const { plainCodeChallenge } = this.config; let tokenSet; let userInfo; try { const client = await this.getClient(); const codeChallenge = plainCodeChallenge ? payload["codeVerifier"] : generators.codeChallenge(payload["codeVerifier"]); tokenSet = await client.callback(payload["callbackUrl"], { code: payload["code"], state: payload["state"], iss: payload["iss"] }, { code_verifier: payload["codeVerifier"], state: codeChallenge, nonce: codeChallenge }); userInfo = tokenSet.claims(); if (client.issuer.metadata["userinfo_endpoint"]) userInfo = { ...userInfo, ...await 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(`[OpenID] Configured group claim with name "${groupClaimName}" does not exist or is empty.`); userInfo = flatten(userInfo); const { provider, identifierKey, allowPublicRegistration, requireVerifiedEmail, syncUserInfo } = this.config; const email = userInfo["email"] ? String(userInfo["email"]) : void 0; const identifier = userInfo[identifierKey ?? "sub"] ? String(userInfo[identifierKey ?? "sub"]) : email; if (!identifier) { logger.warn(`[OpenID] Failed to find user identifier for provider "${provider}"`); throw new InvalidCredentialsError(); } const userPayload = { provider, first_name: userInfo["given_name"], last_name: userInfo["family_name"], 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; } const isEmailVerified = !requireVerifiedEmail || userInfo["email_verified"]; if (!allowPublicRegistration || !isEmailVerified) { logger.warn(`[OpenID] 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, "[OpenID] 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(`[OpenID] Session data isn't valid JSON: ${authData}`); } if (authData?.["refreshToken"]) try { const tokenSet = await (await this.getClient()).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, `[OpenID] Invalid grant`); return new InvalidTokenError(); } logger.warn(e, `[OpenID] Unknown OP error`); return new ServiceUnavailableError({ service: "openid", reason: `Service returned unexpected response: ${e.error_description}` }); } else if (e instanceof errors.RPError) { logger.warn(e, `[OpenID] Unknown RP error`); return new InvalidCredentialsError(); } logger.warn(e, `[OpenID] Unknown error`); return e; }; function createOpenIDAuthRouter(providerName) { const env = useEnv(); const router = Router(); router.use(checkSsoEnabled); router.get("/", async_handler_default(async (req, res) => { const provider = getAuthProvider(providerName); const codeVerifier = provider.generateCodeVerifier(); const prompt = !!req.query["prompt"]; let redirect = req.query["redirect"]; const otp = req.query["otp"]; 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: env[`AUTH_${providerName.toUpperCase()}_LOGIN_TIMEOUT`] ?? "5m", issuer: "directus" }); res.cookie(`openid.${providerName}`, token, { httpOnly: true, sameSite: "lax", secure: Boolean(env[`AUTH_${providerName.toUpperCase()}_COOKIE_SECURE`]) }); try { return res.redirect(await provider.generateAuthUrl(codeVerifier, prompt, callbackUrl)); } catch { return res.redirect(new Url(env["PUBLIC_URL"]).addPath("admin", "login").setQuery("reason", ErrorCode.ServiceUnavailable).toString()); } }), 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 env$1 = useEnv(); const logger = useLogger(); let tokenData; try { tokenData = verifyJWT(req.cookies[`openid.${providerName}`], getSecret()); } catch (e) { logger.warn(e, `[OpenID] Couldn't verify OpenID cookie`); const url = new Url(env$1["PUBLIC_URL"]).addPath("admin", "login"); return res.redirect(`${url.toString()}?reason=${ErrorCode.InvalidCredentials}`); } 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$1[`AUTH_${providerName.toUpperCase()}_MODE`] ?? "session"; let authResponse; try { res.clearCookie(`openid.${providerName}`); authResponse = await authenticationService.login(providerName, { code: req.query["code"], codeVerifier: verifier, state: req.query["state"], iss: req.query["iss"], callbackUrl }, { session: authMode === "session", ...otp ? { otp: String(otp) } : {} }); } catch (error) { if (isDirectusError(error, ErrorCode.InvalidToken) && !prompt) return res.redirect(`./?${redirect ? `redirect=${redirect}&` : ""}prompt=true`); logger.warn(error); if (redirect) { let reason = "UNKNOWN_EXCEPTION"; if (isDirectusError(error)) reason = error.code; else logger.warn(error, `[OpenID] Unexpected error during OpenID login`); return res.redirect(`${redirect.split("?")[0]}?reason=${reason}`); } logger.warn(error, `[OpenID] Unexpected error during OpenID login`); throw error; } const { accessToken, refreshToken, expires } = authResponse; try { if (verifyJWT(accessToken, getSecret())?.enforce_tfa === true) { const url = new Url(env$1["PUBLIC_URL"]).addPath("admin", "tfa-setup"); if (redirect) { url.setQuery("redirect", redirect); url.setQuery("provider", providerName); } redirect = url.toString(); } } catch (e) { logger.warn(e, `[OpenID] Unexpected error during OpenID login`); } if (redirect) { if (authMode === "session") res.cookie(env$1["SESSION_COOKIE_NAME"], accessToken, SESSION_COOKIE_OPTIONS); else res.cookie(env$1["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 { OpenIDAuthDriver, createOpenIDAuthRouter };