diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..7a050e6 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,47 @@ +name: Create and publish a Docker image + + +on: + release: + types: + - published + - prereleased + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 1d7f015..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Merge to Main - -on: - push: - branches: [ main ] -jobs: - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - strategy: - matrix: - node: [ 18, 20 ] - - name: Node ${{ matrix.node }} Release - steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2 - with: - version: 8 - - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node }} - cache: 'pnpm' - - run: pnpm install - - run: pnpm lint - - run: pnpm test diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 50b6276..3b31f4a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,7 +2,9 @@ name: Pull Request on: pull_request: - branches: [ main ] + branches: + - main + - next jobs: check: @@ -27,8 +29,10 @@ jobs: name: install dependencies - run: pnpm lint name: linting - - run: pnpm test + - run: pnpm test:coverage name: testing + - run: pnpm build + name: build package - name: 'Report Coverage' if: always() # Also generate the report if tests are failing uses: davelosert/vitest-coverage-report-action@v2 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ccb528..2af50cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,10 @@ jobs: cache: 'pnpm' - name: Install dependencies run: pnpm install + - run: pnpm lint + name: linting + - run: pnpm test + name: testing - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies run: pnpm audit signatures - name: Release diff --git a/.gitignore b/.gitignore index e38fe1e..46174e1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ extensions/ uploads/ .env +# Redis files +./*.rdb + # Code editor files .vscode diff --git a/README.md b/README.md index 1ded6af..0510325 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,15 @@ 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. +## Configuration +all configuration options listed here are an extension to directus default config. - -|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 \ No newline at end of file +| ENV Variable | Supported values | Description | +|------------------------------|-------------------|--------------| +| AUTH_PROVIDER_TRUSTED | True/False | 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 | String | What key in the JWT payload contains a bool to grant admin rights | +| AUTH_PROVIDER_JWT_APP_KEY | String | What key in the JWT payload contains a bool to allow app access +| AUTH_PROVIDER_JWT_USEDB | Bool | If enabled/true the plugin will resolve the user and roles from the directus database using the token. For OIDC the sub is used. Should not be used without a Redis Cache enabled. +| CACHE_JWT_NAMESPACE | String | What namespace to use in cache store. +| CACHE_JWT_TTL | Number | Time to live for the cached user entry, default 5000 (5 seconds) diff --git a/package.json b/package.json index 3bfb0c8..56f655a 100644 --- a/package.json +++ b/package.json @@ -103,8 +103,10 @@ "vitest": "^0.34.1" }, "dependencies": { + "@keyv/redis": "^2.7.0", "jsonwebtoken": "^9.0.1", "jwks-rsa": "^3.0.1", + "keyv": "^4.5.3", "openid-client": "^5.4.3" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c72fba..ed161fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,12 +5,18 @@ settings: excludeLinksFromLockfile: false dependencies: + '@keyv/redis': + specifier: ^2.7.0 + version: 2.7.0 jsonwebtoken: specifier: ^9.0.1 version: 9.0.1 jwks-rsa: specifier: ^3.0.1 version: 3.0.1 + keyv: + specifier: ^4.5.3 + version: 4.5.3 openid-client: specifier: ^5.4.3 version: 5.4.3 @@ -892,6 +898,10 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -997,6 +1007,15 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@keyv/redis@2.7.0: + resolution: {integrity: sha512-GYqCT+iEP93+gVVPzhW4kmkr/9KTmwb88wkglX6aUMSP50JIhUhNF/yXH0aQTZRPsWfPKO10NJjUZzEh7YW6yw==} + engines: {node: '>= 14'} + dependencies: + ioredis: 5.3.2 + transitivePeerDependencies: + - supports-color + dev: false + /@mapbox/node-pre-gyp@1.0.11: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -2303,6 +2322,11 @@ packages: engines: {node: '>=0.8'} dev: true + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2624,6 +2648,11 @@ packages: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: true + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3856,6 +3885,23 @@ packages: engines: {node: '>= 0.10'} dev: true + /ioredis@5.3.2: + resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.4(supports-color@8.1.1) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /ip@2.0.0: resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} requiresBuild: true @@ -4241,6 +4287,10 @@ packages: hasBin: true dev: true + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: false + /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true @@ -4318,6 +4368,12 @@ packages: safe-buffer: 5.2.1 dev: false + /keyv@4.5.3: + resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==} + dependencies: + json-buffer: 3.0.1 + dev: false + /knex@2.5.1(sqlite3@5.1.6): resolution: {integrity: sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA==} engines: {node: '>=12'} @@ -4413,10 +4469,18 @@ packages: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} dev: false + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + /lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} dev: true + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -5644,6 +5708,18 @@ packages: resolve: 1.22.2 dev: true + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} dev: true @@ -6027,6 +6103,10 @@ packages: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /std-env@3.3.3: resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} dev: true diff --git a/src/external-jwt/authProvider/get-auth-providers.ts b/src/external-jwt/authProvider/get-auth-providers.ts index bb9b273..812fc8b 100644 --- a/src/external-jwt/authProvider/get-auth-providers.ts +++ b/src/external-jwt/authProvider/get-auth-providers.ts @@ -3,7 +3,7 @@ import {JwksClient} from 'jwks-rsa'; import { Issuer } from 'openid-client'; -import env from '../config/config.js'; +import env from '../config/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); diff --git a/src/external-jwt/cache.ts b/src/external-jwt/cache.ts new file mode 100644 index 0000000..3718d25 --- /dev/null +++ b/src/external-jwt/cache.ts @@ -0,0 +1,54 @@ +import {default as Keyv, Store} from 'keyv'; +import env from './config/config'; +import {default as KeyvRedis} from '@keyv/redis'; +// check if redis is defined + +const cache: Keyv | null = getCache(); + +function getCache(): Keyv | null { + if(env['CACHE_ENABLED'] !== true) return null; + + // check namespace + let namespace = env['CACHE_JWT_NAMESPACE']; + if(namespace == null || namespace === '') { + namespace = 'exjwt'; + } + + let ttl = env['CACHE_JWT_TTL']; + if (ttl == null || ttl === '') { + ttl = 5000 + } + + let uri = ''; + let store: Store | undefined = undefined; + if(env['CACHE_STORE'] === 'redis') { + uri = env['REDIS'] + + if(uri == null || uri === '') { + uri = `redis://${env['REDIS_USERNAME']}:${env['REDIS_PASSWORD']}@${env['REDIS_HOST']}:${env['REDIS_PORT']}`; + } + + store = new KeyvRedis(uri); + + } + + return new Keyv(uri, { + namespace: namespace, + ttl, + store: store + }); +} + +export function CacheEnabled(): boolean { + return cache !== null; +} + +export async function CacheSet(key: string, value: any) { + if(cache === null) return false; + return cache.set(key, value); +} + +export async function CacheGet(key: string) { + if(cache === null) return null; + return cache.get(key); +} \ No newline at end of file diff --git a/src/external-jwt/config/config.ts b/src/external-jwt/config/config.ts index 23f6f68..89fc413 100644 --- a/src/external-jwt/config/config.ts +++ b/src/external-jwt/config/config.ts @@ -25,13 +25,14 @@ const allowedEnvironmentVars = [ 'CACHE_VALUE_MAX_SIZE', 'CACHE_SKIP_ALLOWED', 'CACHE_HEALTHCHECK_THRESHOLD', + // Externl JWT Cache + 'CACHE_JWT_NAMESPACE', // redis 'REDIS', 'REDIS_HOST', 'REDIS_PORT', 'REDIS_USERNAME', 'REDIS_PASSWORD', - 'REDIS_JWT_DB', // auth 'AUTH_PROVIDERS', 'AUTH_.+_DRIVER', diff --git a/src/external-jwt/get-accountability-for-token.ts b/src/external-jwt/get-accountability-for-token.ts index 7e0b537..7b291b2 100644 --- a/src/external-jwt/get-accountability-for-token.ts +++ b/src/external-jwt/get-accountability-for-token.ts @@ -2,6 +2,7 @@ import type { Accountability } from '@directus/types'; import { getAuthProviders } from './authProvider/get-auth-providers.js'; import type { Knex } from 'knex'; import { verify_token } from './verify-token.js'; +import { CacheEnabled, CacheGet, CacheSet } from './cache.js'; @@ -22,7 +23,7 @@ export async function getAccountabilityForToken( accountability: Accountability | null, database: Knex ): Promise { - if (!accountability) { + if (accountability == null) { accountability = { user: null, role: null, @@ -32,80 +33,92 @@ export async function getAccountabilityForToken( } if (token == null || iss == null) { + return accountability } + + const providers = authProviders.filter((provider) => provider.issuer_url && iss.includes(provider.issuer_url)); + + if(providers.length === 0) return accountability; + if(providers.length > 1) { + return accountability; + } + - 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) { - console.log("to many matching providers"); - return accountability; - } + const provider = providers[0]; - const provider = providers[0]; + + + try { + const result = await verify_token(provider, token) - 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; - - - } + + + if(provider.use_database) { // use database to get user + // TODO: Add caching to this function + if (CacheEnabled() && result.sub) { - console.log(accountability); - - resolve(accountability); + const cachedAccountability = await CacheGet(result.sub); + if (cachedAccountability) { + return cachedAccountability; + } } - reject("no accountability"); + 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) { + return accountability; + } + 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; + + if (CacheEnabled() && result.sub) { + CacheSet(result.sub, accountability); + } - }) + return accountability; + } - - + // check if role key is set else try role key + if(provider.role_key != null) { + if(typeof result[provider.role_key] === 'string') { + accountability.role = result[provider.role_key]; + } + if(typeof result[provider.role_key] === 'object') { + accountability.role = '' + } + if(result[provider.role_key].instanceOf(Array)) { + accountability.role = result[provider.role_key][0]; + } + } - }); + 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; + + } catch (error) { + return accountability; + } + + + return accountability; } \ No newline at end of file