first commit
This commit is contained in:
290
src/external-jwt/config.ts
Normal file
290
src/external-jwt/config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/external-jwt/get-accountability-for-token.ts
Normal file
71
src/external-jwt/get-accountability-for-token.ts
Normal 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;
|
||||
}
|
||||
119
src/external-jwt/get-auth-providers.ts
Normal file
119
src/external-jwt/get-auth-providers.ts
Normal 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);
|
||||
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
7
src/external-jwt/require-yaml.ts
Normal file
7
src/external-jwt/require-yaml.ts
Normal 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
31
src/index.ts
Normal 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
17
src/types/express.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user