second commit
This commit is contained in:
13
README.md
Normal file
13
README.md
Normal 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
|
||||
@@ -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
2951
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -110,8 +137,10 @@ function getJWKS(issuer_url: string, jwks_url: string, jwks_keys: string): Promi
|
||||
} catch (error) {
|
||||
throw new InvalidJWKSUrl();
|
||||
}
|
||||
|
||||
provider.JWKSClient = jwksClient;
|
||||
|
||||
resolve(jwksClient);
|
||||
resolve(provider);
|
||||
|
||||
})
|
||||
|
||||
@@ -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}$`));
|
||||
@@ -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 accountability;
|
||||
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;
|
||||
}
|
||||
|
||||
const provider = providers[0];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
console.log(decoded)
|
||||
|
||||
// We have a valid token, validate user against database.
|
||||
// We must also check against the correct provider.
|
||||
reject("no accountability");
|
||||
|
||||
// TODO: add cache support
|
||||
// TODO: add database check
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
return accountability;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return accountability;
|
||||
}
|
||||
31
src/external-jwt/verify-token.ts
Normal file
31
src/external-jwt/verify-token.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
18
src/index.ts
18
src/index.ts
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
"include": ["./src/**/*.ts"],
|
||||
"extends": "@directus/tsconfig/node18-esm"
|
||||
"sourceMap": true,
|
||||
"target": "es2022",
|
||||
"types": ["node"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user