second commit

This commit is contained in:
Krise
2023-08-01 09:35:30 +02:00
parent 8d53b15367
commit e44a5bb527
9 changed files with 3158 additions and 86 deletions

13
README.md Normal file
View File

@@ -0,0 +1,13 @@
# External JWT Plugin for Directus
## This plugin serves as a way to make Directus trust externally signed JWT tokens from an OIDC or OAuth2 provider.
The plugin expects to resolve the following new configuration option
The provider must issues Access tokens as JWT since this is used for verification right now. Might add support for general tokens later.
|AUTH_PROVIDER_TRUSTED| True | Must be true for the provider to be considered as trusted. Note, do not trust public providers as these can generate tokens that you cannot control.
|AUTH_PROVIDER_JWT_ROLE_KEY | String | What key in the JWT payload contains the role
|AUTH_PROVIDER_JWT_ADMIN_KEY | Boolean | What key in the JWT payload contains a bool to grant admin rights
|AUTH_PROVIDER_JWT_APP_KEY | Boolean | What key in the JWT payload contains a bool to allow app access

View File

@@ -19,7 +19,8 @@
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w --no-minify",
"link": "directus-extension link"
"link": "directus-extension link",
"directus": "npx directus start"
},
"devDependencies": {
"@directus/errors": "^0.0.2",
@@ -38,6 +39,7 @@
"dotenv": "^16.3.1",
"fs-extra": "^11.1.1",
"js-yaml": "^4.1.0",
"knex": "^2.5.1",
"lodash-es": "^4.17.21",
"typescript": "^5.1.6"
},

2951
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,29 +3,38 @@ import {JwksClient} from 'jwks-rsa';
import { Issuer } from 'openid-client';
import env from './config';
import env from '../config/config.js';
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 {
export interface AuthProvider {
label: string;
name: string;
driver: string;
icon?: string;
client_id: string;
client_secret?: string;
trusted: boolean;
jwks_url?: string;
jwks_keys?: string;
issuer_url?: string;
admin_key?: string;
app_key?: string;
role_key?: string;
JWKSClient?: JwksClient;
use_database?: boolean;
}
export async function getAuthProviders(): Promise<JwksClient[]> {
export async function getAuthProviders(): Promise<AuthProvider[]> {
console.log("calling auth providers")
return new Promise(async (resolve, reject) => {
const authProviders = toArray(env['AUTH_PROVIDERS'])
.filter((provider) => provider && env[`AUTH_${provider.toUpperCase()}_DRIVER`])
const authProviders: AuthProvider[] = toArray(env['AUTH_PROVIDERS'])
.filter((provider) => provider && env[`AUTH_${provider.toUpperCase()}_DRIVER`] === ('openid' || 'oauth2'))
.map((provider) => ({
name: provider,
label: env[`AUTH_${provider.toUpperCase()}_LABEL`],
@@ -35,6 +44,12 @@ export async function getAuthProviders(): Promise<JwksClient[]> {
jwks_url: env[`AUTH_${provider.toUpperCase()}_JWKS_URL`],
jwks_keys: env[`AUTH_${provider.toUpperCase()}_JWKS_KEYS`],
issuer_url: env[`AUTH_${provider.toUpperCase()}_ISSUER_URL`],
admin_key: env[`AUTH_${provider.toUpperCase()}_JWT_ADMIN_KEY`],
app_key: env[`AUTH_${provider.toUpperCase()}_JWT_APP_KEY`],
role_key: env[`AUTH_${provider.toUpperCase()}_JWT_ROLE_KEY`],
client_id: env[`AUTH_${provider.toUpperCase()}_CLIENT_ID`],
client_secret: env[`AUTH_${provider.toUpperCase()}_CLIENT_SECRET`],
use_database: env[`AUTH_${provider.toUpperCase()}_JWT_USEDB`],
}));
@@ -47,12 +62,15 @@ export async function getAuthProviders(): Promise<JwksClient[]> {
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));
if (!authProvider.trusted || (authProvider.issuer_url == null && authProvider.jwks_url == null && authProvider.jwks_keys == null)) break;
//promises.push(getJWKS(authProvider.issuer_url, authProvider.jwks_url, authProvider.jwks_keys));
promises.push(getJWKS(authProvider));
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));
if (!authProvider.trusted || (authProvider.issuer_url == null && authProvider.jwks_url == null && authProvider.jwks_keys == null)) break;
//promises.push(getJWKS(authProvider.issuer_url, authProvider.jwks_url, authProvider.jwks_keys));
promises.push(getJWKS(authProvider));
break;
}
}
@@ -66,26 +84,30 @@ export async function getAuthProviders(): Promise<JwksClient[]> {
});
}
function getJWKS(issuer_url: string, jwks_url: string, jwks_keys: string): Promise<JwksClient>{
function getJWKS(provider: AuthProvider): Promise<AuthProvider>{
return new Promise(async (resolve, reject) => {
if(jwks_keys && !issuer_url && !jwks_url) {
if(provider.jwks_keys != null && provider.issuer_url == null && provider.jwks_url == null) {
const jwksClient = new JwksClient({
getKeysInterceptor: () => {
return JSON.parse(jwks_keys);
return JSON.parse(provider.jwks_keys);
},
jwksUri: ''
})
resolve(jwksClient);
provider.JWKSClient = jwksClient;
resolve(provider);
return;
}
if(issuer_url && !jwks_url) {
if(provider.issuer_url && !provider.jwks_url) {
//try to discover with openid
try {
const issuer = await Issuer.discover(issuer_url);
const issuer = await Issuer.discover(provider.issuer_url);
if(issuer.metadata.jwks_uri != null) {
jwks_url = issuer.metadata.jwks_uri;
provider.jwks_url = issuer.metadata.jwks_uri;
}
} catch (error) {
//throw new InvalidJWKIssuerMetadata();
@@ -93,8 +115,13 @@ function getJWKS(issuer_url: string, jwks_url: string, jwks_keys: string): Promi
}
}
if(!provider.jwks_url) {
reject("No JWKS_URL or JWKS_KEYS and could not discover JWKS_URL from openid metadata")
return;
}
const jwksClient = new JwksClient({
jwksUri: jwks_url,
jwksUri: provider.jwks_url,
cache: true,
cacheMaxAge: 36000000, // 10 hours
cacheMaxEntries: 10,
@@ -111,7 +138,9 @@ function getJWKS(issuer_url: string, jwks_url: string, jwks_keys: string): Promi
throw new InvalidJWKSUrl();
}
resolve(jwksClient);
provider.JWKSClient = jwksClient;
resolve(provider);
})

View File

@@ -3,7 +3,7 @@ import dotenv from 'dotenv';
import fs from 'fs';
import { clone, toNumber, toString } from 'lodash-es';
import path from 'path';
import { requireYAML } from './require-yaml';
import { requireYAML } from '../require-yaml.js';
// keeping this here for now to prevent a circular import to constants.ts
@@ -45,6 +45,10 @@ const allowedEnvironmentVars = [
'AUTH_.+_TRUSTED',
'AUTH_.+_JWKS_URL',
'AUTH_.+_JWKS_KEYS',
'AUTH_.+_JWT_ROLE_KEY',
'AUTH_.+_JWT_ADMIN_KEY',
'AUTH_.+_JWT_APP_KEY',
'AUTH_.+_JWT_USEDB',
'AUTH_.+_IDP.+',
'AUTH_.+_SP.+',
].map((name) => new RegExp(`^${name}$`));

View File

@@ -1,35 +1,29 @@
import type { Accountability } from '@directus/types';
import type { JwtHeader, VerifyCallback} from 'jsonwebtoken';
import {JsonWebTokenError} from 'jsonwebtoken';
import { getAuthProviders } from './get-auth-providers';
import { getAuthProviders } from './authProvider/get-auth-providers.js';
import jwt from 'jsonwebtoken';
import type { Knex } from 'knex';
import { createError } from '@directus/errors';
import { verify_token } from './verify-token.js';
import { forEach } from 'lodash-es';
const authProviders = getAuthProviders();
const authProviders = await getAuthProviders();
const MissingJWTHeaderError = createError('INVALID_JWKS_ISSUER_ERROR', 'No header in JWT Token', 500);
const NoValidKeysError = createError('INVALID_JWKS_ISSUER_ERROR', 'could not retrieve any valid keys with key id(kid)', 500);
const NoAuthProvidersError = createError('INVALID_JWKS_ISSUER_ERROR', 'No auth providers in the list', 500);
// 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
token: string | null,
iss: string[] | string | undefined,
accountability: Accountability | null,
database: Knex
): Promise<Accountability> {
if (!accountability) {
accountability = {
@@ -40,32 +34,81 @@ export async function getAccountabilityForToken(
};
}
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
if (token == null || iss == null) {
return accountability
}
jwt.verify(token, getKey,{
}, function(err, decoded) {
if (err) {
console.log(err)
return new Promise(async (resolve, reject) => {
const providers = authProviders.filter((provider) => provider && iss.includes(provider.client_id));
if(providers.length === 0) return accountability;
if(providers.length > 1) {
console.log("to many matching providers");
return accountability;
}
console.log(decoded)
const provider = providers[0];
// We have a valid token, validate user against database.
// We must also check against the correct provider.
let promises = [];
verify_token(provider, token).then(async (result) => {
if(accountability) {
// check if role key is set else try role key
if(provider.role_key != null) {
accountability.role = typeof result[provider.role_key] === 'string' ? result[provider.role_key] : result[provider.role_key][0];
} else {
if (result.role) {
accountability.role = result.role;
}
}
if(provider.use_database) { // use database to get user
// TODO: Add caching to this function
const user = await database
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
.from('directus_users')
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
.where({
'directus_users.external_identifier': result.sub,
'directus_users.provider': provider.name,
})
.first();
if(!user) {
reject("invalid user credentials");
}
accountability.user = user.id;
accountability.role = user.role;
accountability.admin = user.admin_access === true || user.admin_access == 1;
accountability.app = user.app_access === true || user.app_access == 1;
} else {
if(provider.admin_key != null) {
accountability.admin = result[provider.admin_key];
}
if(provider.app_key != null) {
accountability.app = result[provider.app_key];
}
accountability.user = result.sub;
}
console.log(accountability);
resolve(accountability);
}
reject("no accountability");
})
// TODO: add cache support
// TODO: add database check
return accountability;
});
}
return accountability;
}

View File

@@ -0,0 +1,31 @@
import type { JwksClient } from "jwks-rsa";
import type { AuthProvider } from "./authProvider/get-auth-providers.js";
import jwt from 'jsonwebtoken';
export function verify_token(provider: AuthProvider, token: string): Promise<jwt.JwtPayload> {
return new Promise((resolve, reject) => {
if (provider.JWKSClient === undefined){
return reject('JWKSClient not initialized');
}
jwt.verify(
token,
(header, callback) => {
provider.JWKSClient?.getSigningKey(header.kid, (err, key) => {
const signingKey = key?.getPublicKey();
callback(err, signingKey);
});
},
{
complete: false,
},
(err, decoded) => {
if (err || decoded === undefined || typeof decoded === 'string') {
return reject(err);
}
return resolve(decoded);
}
)
})
}

View File

@@ -4,6 +4,7 @@ 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';
import jwt from 'jsonwebtoken';
const InvalidTokenError = createError('INVALID_TOKEN_ERROR', 'Could not validate external JWT token', 500);
@@ -13,13 +14,22 @@ export default defineHook(({ filter }) => {
// get all configuration
filter('authenticate', (accountability, event, context) => {
filter('authenticate', (defaultAccountability, event, context) => {
let req = <Request>event['req'];
let account = <Accountability>accountability;
if(!req.token) return defaultAccountability;
if(!req.token) return accountability;
if(!context.database) {
return defaultAccountability
}
return getAccountabilityForToken(req.token, account)
const decodedToken = jwt.decode(req.token);
if(typeof decodedToken === 'string') return defaultAccountability; // if token is not a jwt, let directus handle it
if(decodedToken?.iss == 'directus') return defaultAccountability; // if token issued by directus, let directus handle it
return getAccountabilityForToken(req.token, decodedToken?.iss, context.accountability, context.database)
});
filter('auth.jwt', (status, user, provider) => {

View File

@@ -1,27 +1,16 @@
{
"compilerOptions": {
"esModuleInterop": true,
"lib": ["es2020"],
"module": "es2022",
"preserveConstEnums": true,
"moduleResolution": "node",
"strict": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"noUnusedParameters": true,
"alwaysStrict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"resolveJsonModule": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"rootDir": "./src"
"sourceMap": true,
"target": "es2022",
"types": ["node"],
"outDir": "dist"
},
"include": ["./src/**/*.ts"],
"extends": "@directus/tsconfig/node18-esm"
"include": ["src/**/*"],
"exclude": ["node_modules"]
}