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

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,
@@ -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);
})

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 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;
}

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) => {