first commit

This commit is contained in:
Krise
2023-07-31 02:55:15 +02:00
commit 8d53b15367
11 changed files with 5987 additions and 0 deletions

290
src/external-jwt/config.ts Normal file
View File

@@ -0,0 +1,290 @@
import { parseJSON, toArray } from '@directus/utils';
import dotenv from 'dotenv';
import fs from 'fs';
import { clone, toNumber, toString } from 'lodash-es';
import path from 'path';
import { requireYAML } from './require-yaml';
// keeping this here for now to prevent a circular import to constants.ts
const allowedEnvironmentVars = [
// general
'CONFIG_PATH',
// auth
'AUTH_PROVIDERS',
'AUTH_.+_DRIVER',
'AUTH_.+_CLIENT_ID',
'AUTH_.+_CLIENT_SECRET',
'AUTH_.+_SCOPE',
'AUTH_.+_AUTHORIZE_URL',
'AUTH_.+_ACCESS_URL',
'AUTH_.+_PROFILE_URL',
'AUTH_.+_IDENTIFIER_KEY',
'AUTH_.+_EMAIL_KEY',
'AUTH_.+_FIRST_NAME_KEY',
'AUTH_.+_LAST_NAME_KEY',
'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
'AUTH_.+_DEFAULT_ROLE_ID',
'AUTH_.+_ICON',
'AUTH_.+_LABEL',
'AUTH_.+_PARAMS',
'AUTH_.+_ISSUER_URL',
'AUTH_.+_AUTH_REQUIRE_VERIFIED_EMAIL',
'AUTH_.+_CLIENT_URL',
'AUTH_.+_BIND_DN',
'AUTH_.+_BIND_PASSWORD',
'AUTH_.+_USER_DN',
'AUTH_.+_USER_ATTRIBUTE',
'AUTH_.+_USER_SCOPE',
'AUTH_.+_MAIL_ATTRIBUTE',
'AUTH_.+_FIRST_NAME_ATTRIBUTE',
'AUTH_.+_LAST_NAME_ATTRIBUTE',
'AUTH_.+_GROUP_DN',
'AUTH_.+_GROUP_ATTRIBUTE',
'AUTH_.+_GROUP_SCOPE',
'AUTH_.+_TRUSTED',
'AUTH_.+_JWKS_URL',
'AUTH_.+_JWKS_KEYS',
'AUTH_.+_IDP.+',
'AUTH_.+_SP.+',
].map((name) => new RegExp(`^${name}$`));
const acceptedEnvTypes = ['string', 'number', 'regex', 'array', 'json'];
const typeMap: Record<string, string> = {}
const defaults: Record<string, any> = {
CONFIG_PATH: path.resolve(process.cwd(), '.env')
};
let env: Record<string, any> = {
...defaults,
...process.env,
...processConfiguration(),
};
process.env = env;
env = processValues(env);
export default env;
/**
* Small wrapper function that makes it easier to write unit tests against changing environments
*/
export const getEnv = () => env;
/**
* When changes have been made during runtime, like in the CLI, we can refresh the env object with
* the newly created variables
*/
export function refreshEnv(): void {
env = {
...defaults,
...process.env,
...processConfiguration(),
};
process.env = env;
env = processValues(env);
}
function toBoolean(value: any): boolean {
return value === 'true' || value === true || value === '1' || value === 1;
}
function processConfiguration() {
const configPath = path.resolve(process.env['CONFIG_PATH'] || defaults['CONFIG_PATH']);
if (fs.existsSync(configPath) === false) return {};
const fileExt = path.extname(configPath).toLowerCase();
if (fileExt === '.js') {
const module = require(configPath);
const exported = module.default || module;
if (typeof exported === 'function') {
return exported(process.env);
} else if (typeof exported === 'object') {
return exported;
}
throw new Error(
`Invalid JS configuration file export type. Requires one of "function", "object", received: "${typeof exported}"`
);
}
if (fileExt === '.json') {
return require(configPath);
}
if (fileExt === '.yaml' || fileExt === '.yml') {
const data = requireYAML(configPath);
if (typeof data === 'object') {
return data as Record<string, string>;
}
throw new Error('Invalid YAML configuration. Root has to be an object.');
}
// Default to env vars plain text files
return dotenv.parse(fs.readFileSync(configPath, { encoding: 'utf8' }));
}
function getVariableType(variable: string) {
return variable.split(':').slice(0, -1)[0];
}
function getEnvVariableValue(variableValue: string, variableType: string) {
return variableValue.split(`${variableType}:`)[1];
}
function getEnvironmentValueWithPrefix(envArray: Array<string>): Array<string | number | RegExp> {
return envArray.map((item: string) => {
if (isEnvSyntaxPrefixPresent(item)) {
return getEnvironmentValueByType(item);
}
return item;
});
}
function getEnvironmentValueByType(envVariableString: string) {
const variableType = getVariableType(envVariableString)!;
const envVariableValue = getEnvVariableValue(envVariableString, variableType)!;
switch (variableType) {
case 'number':
return toNumber(envVariableValue);
case 'array':
return getEnvironmentValueWithPrefix(toArray(envVariableValue));
case 'regex':
return new RegExp(envVariableValue);
case 'string':
return envVariableValue;
case 'json':
return tryJSON(envVariableValue);
}
}
function isEnvSyntaxPrefixPresent(value: string): boolean {
return acceptedEnvTypes.some((envType) => value.includes(`${envType}:`));
}
function processValues(env: Record<string, any>) {
env = clone(env);
for (let [key, value] of Object.entries(env)) {
// If key ends with '_FILE', try to get the value from the file defined in this variable
// and store it in the variable with the same name but without '_FILE' at the end
let newKey: string | undefined;
if (key.length > 5 && key.endsWith('_FILE')) {
newKey = key.slice(0, -5);
if (allowedEnvironmentVars.some((pattern) => pattern.test(newKey as string))) {
if (newKey in env && !(newKey in defaults && env[newKey] === defaults[newKey])) {
throw new Error(
`Duplicate environment variable encountered: you can't use "${newKey}" and "${key}" simultaneously.`
);
}
try {
value = fs.readFileSync(value, { encoding: 'utf8' });
key = newKey;
} catch {
throw new Error(`Failed to read value from file "${value}", defined in environment variable "${key}".`);
}
}
}
// Convert values with a type prefix
// (see https://docs.directus.io/reference/environment-variables/#environment-syntax-prefix)
if (typeof value === 'string' && isEnvSyntaxPrefixPresent(value)) {
env[key] = getEnvironmentValueByType(value);
continue;
}
// Convert values where the key is defined in typeMap
if (typeMap[key]) {
switch (typeMap[key]) {
case 'number':
env[key] = toNumber(value);
break;
case 'string':
env[key] = toString(value);
break;
case 'array':
env[key] = toArray(value);
break;
case 'json':
env[key] = tryJSON(value);
break;
case 'boolean':
env[key] = toBoolean(value);
}
continue;
}
// Try to convert remaining values:
// - boolean values to boolean
// - 'null' to null
// - number values (> 0 <= Number.MAX_SAFE_INTEGER) to number
if (value === 'true') {
env[key] = true;
continue;
}
if (value === 'false') {
env[key] = false;
continue;
}
if (value === 'null') {
env[key] = null;
continue;
}
if (
String(value).startsWith('0') === false &&
isNaN(value) === false &&
value.length > 0 &&
value <= Number.MAX_SAFE_INTEGER
) {
env[key] = Number(value);
continue;
}
if (String(value).includes(',')) {
env[key] = toArray(value);
continue;
}
// Try converting the value to a JS object. This allows JSON objects to be passed for nested
// config flags, or custom param names (that aren't camelCased)
env[key] = tryJSON(value);
// If '_FILE' variable hasn't been processed yet, store it as it is (string)
if (newKey) {
env[key] = value;
}
}
return env;
}
function tryJSON(value: any) {
try {
return parseJSON(value);
} catch {
return value;
}
}

View File

@@ -0,0 +1,71 @@
import type { Accountability } from '@directus/types';
import type { JwtHeader, VerifyCallback} from 'jsonwebtoken';
import {JsonWebTokenError} from 'jsonwebtoken';
import { getAuthProviders } from './get-auth-providers';
import jwt from 'jsonwebtoken';
const authProviders = getAuthProviders();
// TODO: optimize this function, reduce the amount of loops
async function getKey(header: JwtHeader | undefined, callback: VerifyCallback<string>) {
for (const authProvider of (await authProviders)) {
if(!header) return new JsonWebTokenError("No header found")
authProvider.getSigningKey(header.kid, function (err, key) {
if (err) {
return new JsonWebTokenError("Could not retrieve any valid keys with key id(kid)");
}
if(key == null) return new JsonWebTokenError("No valid key found");
const signingKey = key.getPublicKey();
return callback(null, signingKey);
});
}
return new JsonWebTokenError("No auth provider in list");
}
export async function getAccountabilityForToken(
token?: string | null,
accountability?: Accountability
): Promise<Accountability> {
if (!accountability) {
accountability = {
user: null,
role: null,
admin: false,
app: false,
};
}
if (token) {
const decodedToken = jwt.decode(token);
if(typeof decodedToken === 'string') return accountability; // if token is not a jwt, let directus handle it
if(decodedToken?.iss == 'directus') return accountability; // if token issued by directus, let directus handle it
jwt.verify(token, getKey,{
}, function(err, decoded) {
if (err) {
console.log(err)
return accountability;
}
console.log(decoded)
// We have a valid token, validate user against database.
// We must also check against the correct provider.
// TODO: add cache support
// TODO: add database check
return accountability;
});
}
return accountability;
}

View File

@@ -0,0 +1,119 @@
import { toArray } from '@directus/utils';
import {JwksClient} from 'jwks-rsa';
import { Issuer } from 'openid-client';
import env from './config';
import { createError } from '@directus/errors';
const InvalidJWKIssuerMetadata = createError('INVALID_JWKS_ISSUER_ERROR', 'No JWKS_URL or JWKS_KEYS and could not discover JWKS_URL from openid metadata', 500);
const InvalidJWKSUrl = createError('INVALID_JWKS_ISSUER_ERROR', 'Could not retrieve any valid keys from JWKS_URL', 500);
interface AuthProvider {
label: string;
name: string;
driver: string;
icon?: string;
trusted: boolean;
jwks_url?: string;
jwks_keys?: string;
issuer_url?: string;
}
export async function getAuthProviders(): Promise<JwksClient[]> {
return new Promise(async (resolve, reject) => {
const authProviders = toArray(env['AUTH_PROVIDERS'])
.filter((provider) => provider && env[`AUTH_${provider.toUpperCase()}_DRIVER`])
.map((provider) => ({
name: provider,
label: env[`AUTH_${provider.toUpperCase()}_LABEL`],
driver: env[`AUTH_${provider.toUpperCase()}_DRIVER`],
icon: env[`AUTH_${provider.toUpperCase()}_ICON`],
trusted: env[`AUTH_${provider.toUpperCase()}_TRUSTED`],
jwks_url: env[`AUTH_${provider.toUpperCase()}_JWKS_URL`],
jwks_keys: env[`AUTH_${provider.toUpperCase()}_JWKS_KEYS`],
issuer_url: env[`AUTH_${provider.toUpperCase()}_ISSUER_URL`],
}));
if(authProviders.length === 0) return resolve([]);
var promises = [];
for (const authProvider of authProviders) {
switch (authProvider.driver) {
case 'openid':
if (!authProvider.trusted || (!authProvider.issuer_url && !authProvider.jwks_url && !authProvider.jwks_keys)) break;
promises.push(getJWKS(authProvider.issuer_url, authProvider.jwks_url, authProvider.jwks_keys));
break;
case 'oauth2':
if (!authProvider.trusted || (!authProvider.issuer_url && !authProvider.jwks_url && !authProvider.jwks_keys)) break;
promises.push(getJWKS(authProvider.issuer_url, authProvider.jwks_url, authProvider.jwks_keys));
break;
}
}
Promise.all(promises).then((values) => {
resolve(values);
}).catch((error) => {
reject(error);
})
});
}
function getJWKS(issuer_url: string, jwks_url: string, jwks_keys: string): Promise<JwksClient>{
return new Promise(async (resolve, reject) => {
if(jwks_keys && !issuer_url && !jwks_url) {
const jwksClient = new JwksClient({
getKeysInterceptor: () => {
return JSON.parse(jwks_keys);
},
jwksUri: ''
})
resolve(jwksClient);
return;
}
if(issuer_url && !jwks_url) {
//try to discover with openid
try {
const issuer = await Issuer.discover(issuer_url);
if(issuer.metadata.jwks_uri != null) {
jwks_url = issuer.metadata.jwks_uri;
}
} catch (error) {
//throw new InvalidJWKIssuerMetadata();
reject("Could not discover JWKS_URL from openid metadata")
}
}
const jwksClient = new JwksClient({
jwksUri: jwks_url,
cache: true,
cacheMaxAge: 36000000, // 10 hours
cacheMaxEntries: 10,
timeout: 30000, // 30 seconds
});
// try to get the keys
try {
const keys = await jwksClient.getSigningKeys()
if (keys.length == 0) {
reject("Could not retrieve any valid keys from JWKS_URL")
}
} catch (error) {
throw new InvalidJWKSUrl();
}
resolve(jwksClient);
})
}

View File

@@ -0,0 +1,7 @@
import fse from 'fs-extra';
import yaml from 'js-yaml';
export function requireYAML(filepath: string): Record<string, any> {
const yamlRaw = fse.readFileSync(filepath, 'utf8');
return yaml.load(yamlRaw) as Record<string, any>;
}

31
src/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineHook } from '@directus/extensions-sdk';
import { createError } from '@directus/errors';
import { getAccountabilityForToken } from './external-jwt/get-accountability-for-token';
import type { Request } from 'express';
import type { Accountability } from '@directus/types';
const InvalidTokenError = createError('INVALID_TOKEN_ERROR', 'Could not validate external JWT token', 500);
export default defineHook(({ filter }) => {
// get all configuration
filter('authenticate', (accountability, event, context) => {
let req = <Request>event['req'];
let account = <Accountability>accountability;
if(!req.token) return accountability;
return getAccountabilityForToken(req.token, account)
});
filter('auth.jwt', (status, user, provider) => {
})
});

17
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { Accountability, Query, SchemaOverview } from '@directus/types';
export {};
declare global {
namespace Express {
export interface Request {
token: string | null;
collection: string;
sanitizedQuery: Query;
schema: SchemaOverview;
accountability?: Accountability;
singleton?: boolean;
}
}
}