add actions

This commit is contained in:
Kristoffer
2023-08-01 14:42:30 +00:00
parent e44a5bb527
commit 4dc8fbdce6
20 changed files with 52405 additions and 631 deletions

View File

@@ -0,0 +1,24 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers-contrib/features/redis-homebrew:1": {}
}
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

59
.env.example Normal file
View File

@@ -0,0 +1,59 @@
# IP or host the API listens on ["0.0.0.0"]
HOST="0.0.0.0"
# The port Directus will run on [8055]
PORT=8055
PUBLIC_URL="http://localhost:8055"
AUTH_PROVIDER_CLIENT_ID=YOUR_CLIENT_ID
AUTH_PROVIDER_CLIENT_SECRET=YOUR_CLIENT_SECRET
AUTH_PROVIDER_ISSUER_URL=YOUR_ISSUER_URL
AUTH_PROVIDER_TRUSTED=true
AUTH_PROVIDER_JWKS_URL=YOUR_JWKS_URL
AUTH_PROVIDER_JWT_ROLE_KEY=YOUR_JWT_ROLE_KEY
AUTH_PROVIDER_JWT_ADMIN_KEY=YOUR_JWT_ADMIN_KEY
AUTH_PROVIDER_APP_KEY=YOUR_APP_KEY
AUTH_PROVIDER_JWT_USEDB=true
####################################################################################################
### Redis
REDIS_JWT_DB="directus-jwt"
REDIS_DB="directus"
REDIS_HOST="localhost"
REDIS_PORT=6379
####################################################################################################
### Database
# All DB_* environment variables are passed to the connection configuration of a Knex instance.
# Based on your project's needs, you can extend the DB_* environment variables with any config
# you need to pass to the database instance.
DB_CLIENT="sqlite3"
DB_FILENAME="/workspaces/directus-extension-external-jwt/data.db"
####################################################################################################
### File Storage
# A CSV of storage locations (eg: local,digitalocean,amazon) to use. You can use any names you'd like for these keys ["local"]
STORAGE_LOCATIONS="local"
STORAGE_LOCAL_DRIVER="local"
STORAGE_LOCAL_ROOT="./uploads"
####################################################################################################
### Security
KEY="1d5a73c0-fd56-4d8b-ac52-11abade5981e"
SECRET="fa09-kK5PzcRN7R0UiXRiWChdGySjCuJ"
####################################################################################################
### Extensions
# Path to your local extensions folder ["./extensions"]
EXTENSIONS_PATH="./extensions"
# Automatically reload extensions when they have changed [false]
EXTENSIONS_AUTO_RELOAD=true

31
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,31 @@
module.exports = {
"env": {
"browser": false,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js,cjs}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
},
"parser": "@typescript-eslint/parser",
}

0
.github/CODEOWNERS vendored Normal file
View File

13
.github/FUNDING.yaml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: #zerosubnet
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # []

23
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Merge to Main
on:
push:
branches: [ main ]
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
strategy:
matrix:
node: [ 18 ]
name: Node ${{ matrix.node }} sample
steps:
- uses: actions/checkout@v3
- name: Run linting rules and tests
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run lint
- run: npm run test

24
.github/workflows/pr.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Pull Request
on:
pull_request:
branches: [ main ]
jobs:
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
strategy:
matrix:
node: [ 16, 18, 20 ]
name: Node ${{ matrix.node }} sample
steps:
- uses: actions/checkout@v3
- name: Run linting rules and tests
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run lint
- run: npm run test

37
.github/workflows/release.yml.disabled vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
permissions:
contents: read # for checkout
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
node_modules
.pnpm-store
dist
# directus files

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM busybox
RUN mkdir -p /app/hooks/directus-extension-external-jwt/
WORKDIR /app
ADD dist/index.js ./hooks/directus-extension-external-jwt/
ADD docker/install.sh /install.sh
ENTRYPOINT [ "/install.sh" ]

4
docker/install.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
# check if directory /extensions is mounted and copy extension into that dir
[ -d /extensions ] && (echo "Found extension directory on /extensions, copying ext to that directory" && cp -r /app/* /extensions/) || echo "No extension directory found on /extensions, skipping copy"

5050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "directus-extension-external-jwt",
"description": "Please enter a description for your extension",
"description": "External JWT Directus Extension allow directus to trust tokens issued by an oauth2 or OIDC provider",
"icon": "extension",
"version": "1.0.0",
"keywords": [
@@ -24,7 +24,7 @@
},
"devDependencies": {
"@directus/errors": "^0.0.2",
"@directus/extensions-sdk": "10.1.7",
"@directus/extensions-sdk": "^10.1.0",
"@directus/tsconfig": "^1.0.0",
"@directus/types": "^10.1.3",
"@directus/utils": "^10.0.8",
@@ -35,8 +35,14 @@
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash-es": "^4.17.8",
"@types/node": "^20.4.5",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"config": "^3.3.9",
"dotenv": "^16.3.1",
"eslint": "^8.0.1",
"eslint-config-standard-with-typescript": "^37.0.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
"eslint-plugin-promise": "^6.0.0",
"fs-extra": "^11.1.1",
"js-yaml": "^4.1.0",
"knex": "^2.5.1",
@@ -47,6 +53,6 @@
"jsonwebtoken": "^9.0.1",
"jwks-rsa": "^3.0.1",
"openid-client": "^5.4.3",
"remove": "^0.1.5"
"sqlite3": "^5.1.6"
}
}

2044
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ 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);
export interface AuthProvider {
label: string;
name: string;
@@ -32,7 +33,7 @@ export interface AuthProvider {
export async function getAuthProviders(): Promise<AuthProvider[]> {
console.log("calling auth providers")
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
const authProviders: AuthProvider[] = toArray(env['AUTH_PROVIDERS'])
.filter((provider) => provider && env[`AUTH_${provider.toUpperCase()}_DRIVER`] === ('openid' || 'oauth2'))
.map((provider) => ({
@@ -57,7 +58,7 @@ export async function getAuthProviders(): Promise<AuthProvider[]> {
var promises = [];
const promises = [];
for (const authProvider of authProviders) {
switch (authProvider.driver) {
@@ -84,65 +85,56 @@ export async function getAuthProviders(): Promise<AuthProvider[]> {
});
}
function getJWKS(provider: AuthProvider): Promise<AuthProvider>{
return new Promise(async (resolve, reject) => {
if(provider.jwks_keys != null && provider.issuer_url == null && provider.jwks_url == null) {
const jwksClient = new JwksClient({
getKeysInterceptor: () => {
return JSON.parse(provider.jwks_keys);
},
jwksUri: ''
})
provider.JWKSClient = jwksClient;
resolve(provider);
return;
}
if(provider.issuer_url && !provider.jwks_url) {
//try to discover with openid
try {
const issuer = await Issuer.discover(provider.issuer_url);
if(issuer.metadata.jwks_uri != null) {
provider.jwks_url = issuer.metadata.jwks_uri;
}
} catch (error) {
//throw new InvalidJWKIssuerMetadata();
reject("Could not discover JWKS_URL from openid metadata")
}
}
if(!provider.jwks_url) {
reject("No JWKS_URL or JWKS_KEYS and could not discover JWKS_URL from openid metadata")
return;
}
async function getJWKS(provider: AuthProvider) {
if(provider.jwks_keys !== undefined && provider.issuer_url == null && provider.jwks_url == null) {
const jwks_keys = JSON.parse(provider.jwks_keys);
const jwksClient = new JwksClient({
jwksUri: provider.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();
}
getKeysInterceptor: () => {
return jwks_keys;
},
jwksUri: ''
})
provider.JWKSClient = jwksClient;
resolve(provider);
}
})
if(provider.issuer_url && !provider.jwks_url) {
//try to discover with openid
const issuer = await Issuer.discover(provider.issuer_url);
if(issuer.metadata.jwks_uri != null) {
provider.jwks_url = issuer.metadata.jwks_uri;
}
}
if (provider.jwks_url == null) throw new InvalidJWKIssuerMetadata();
const jwksClient = await getJWKSClient(provider.jwks_url);
provider.JWKSClient = jwksClient;
return provider;
}
async function getJWKSClient(url: string) {
const jwksClient = new JwksClient({
jwksUri: 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) {
throw new InvalidJWKSUrl();
}
} catch (error) {
throw new InvalidJWKSUrl();
}
return jwksClient;
}

View File

@@ -10,6 +10,28 @@ import { requireYAML } from '../require-yaml.js';
const allowedEnvironmentVars = [
// general
'CONFIG_PATH',
// cache
'CACHE_ENABLED',
'CACHE_TTL',
'CACHE_CONTROL_S_MAXAGE',
'CACHE_AUTO_PURGE',
'CACHE_AUTO_PURGE_IGNORE_LIST',
'CACHE_SYSTEM_TTL',
'CACHE_SCHEMA',
'CACHE_PERMISSIONS',
'CACHE_NAMESPACE',
'CACHE_STORE',
'CACHE_STATUS_HEADER',
'CACHE_VALUE_MAX_SIZE',
'CACHE_SKIP_ALLOWED',
'CACHE_HEALTHCHECK_THRESHOLD',
// redis
'REDIS',
'REDIS_HOST',
'REDIS_PORT',
'REDIS_USERNAME',
'REDIS_PASSWORD',
'REDIS_JWT_DB',
// auth
'AUTH_PROVIDERS',
'AUTH_.+_DRIVER',
@@ -29,19 +51,7 @@ const allowedEnvironmentVars = [
'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',
@@ -49,8 +59,6 @@ const allowedEnvironmentVars = [
'AUTH_.+_JWT_ADMIN_KEY',
'AUTH_.+_JWT_APP_KEY',
'AUTH_.+_JWT_USEDB',
'AUTH_.+_IDP.+',
'AUTH_.+_SP.+',
].map((name) => new RegExp(`^${name}$`));
const acceptedEnvTypes = ['string', 'number', 'regex', 'array', 'json'];
@@ -97,7 +105,7 @@ export function refreshEnv(): void {
function toBoolean(value: any): boolean {
function toBoolean(value: string | boolean | number): boolean {
return value === 'true' || value === true || value === '1' || value === 1;
}
@@ -110,6 +118,7 @@ function processConfiguration() {
const fileExt = path.extname(configPath).toLowerCase();
if (fileExt === '.js') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const module = require(configPath);
const exported = module.default || module;
@@ -161,8 +170,8 @@ function getEnvironmentValueWithPrefix(envArray: Array<string>): Array<string |
}
function getEnvironmentValueByType(envVariableString: string) {
const variableType = getVariableType(envVariableString)!;
const envVariableValue = getEnvVariableValue(envVariableString, variableType)!;
const variableType = getVariableType(envVariableString) ?? false;
const envVariableValue = getEnvVariableValue(envVariableString, variableType) ?? false;
switch (variableType) {
case 'number':
@@ -285,7 +294,7 @@ function processValues(env: Record<string, any>) {
return env;
}
function tryJSON(value: any) {
function tryJSON(value: string) {
try {
return parseJSON(value);
} catch {

View File

@@ -1,20 +1,17 @@
import type { Accountability } from '@directus/types';
import type { JwtHeader, VerifyCallback} from 'jsonwebtoken';
import {JsonWebTokenError} from 'jsonwebtoken';
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 = 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
@@ -38,7 +35,7 @@ export async function getAccountabilityForToken(
return accountability
}
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
const providers = authProviders.filter((provider) => provider && iss.includes(provider.client_id));
if(providers.length === 0) return accountability;
if(providers.length > 1) {
@@ -48,7 +45,7 @@ export async function getAccountabilityForToken(
const provider = providers[0];
let promises = [];
verify_token(provider, token).then(async (result) => {
if(accountability) {

View File

@@ -1,4 +1,3 @@
import type { JwksClient } from "jwks-rsa";
import type { AuthProvider } from "./authProvider/get-auth-providers.js";
import jwt from 'jsonwebtoken';

View File

@@ -1,21 +1,14 @@
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';
import jwt from 'jsonwebtoken';
const InvalidTokenError = createError('INVALID_TOKEN_ERROR', 'Could not validate external JWT token', 500);
export default defineHook(({ filter }) => {
// get all configuration
filter('authenticate', (defaultAccountability, event, context) => {
let req = <Request>event['req'];
const req = <Request>event['req'];
if(!req.token) return defaultAccountability;
if(!context.database) {
@@ -32,9 +25,9 @@ export default defineHook(({ filter }) => {
return getAccountabilityForToken(req.token, decodedToken?.iss, context.accountability, context.database)
});
filter('auth.jwt', (status, user, provider) => {
/*filter('auth.jwt', (status, user, provider) => {
})
})*/
});

File diff suppressed because it is too large Load Diff