Add authenticate hook to implement custom auth checks against current request (#11942)

* Add "authenticate" filter hook that allows custom auth check

* Start on test

* Update Jest, restructure API tests, start implementing authenticate test

* Move access token verify to util function

* Ensure jest can show inline warnings on correct lines

* Update is-directus-jwt to use jsonwebtoken decode + add tests

* Remove unused package

* Tweak and finish + test authenticate

* Tweak test

* Add authenticate filter to docs

* Don't scan tests for codeql

* No seriously, ignore tests
This commit is contained in:
Rijk van Zanten
2022-03-03 16:29:13 -05:00
committed by GitHub
parent b44f9dd8d0
commit eea9f45624
24 changed files with 1900 additions and 520 deletions

View File

@@ -30,6 +30,13 @@ module.exports = {
sourceType: 'module',
},
},
{
files: ['**/*.test.js'],
env: {
jest: true,
},
plugins: ['jest'],
},
// Configuration for ts/vue files
{
files: ['*.ts', '*.vue'],

4
.github/codeql/codeql-config.yaml vendored Normal file
View File

@@ -0,0 +1,4 @@
paths-ignore:
- '**/*.test.ts'
- '**/*.test.js'
- '**/node_modules'

View File

@@ -19,7 +19,7 @@ jobs:
contents: read
security-events: write
strategy:
fail-fast: false
fail-fast: true
matrix:
language:
- javascript
@@ -30,7 +30,7 @@ jobs:
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v1

View File

@@ -65,7 +65,7 @@
"cli": "cross-env NODE_ENV=development SERVE_APP=false ts-node --script-mode --transpile-only src/cli/run.ts",
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watchAll"
"test:watch": "jest --watch"
},
"engines": {
"node": ">=12.20.0"
@@ -94,7 +94,6 @@
"argon2": "^0.28.2",
"async": "^3.2.0",
"async-mutex": "^0.3.1",
"atob": "^2.1.2",
"axios": "^0.24.0",
"busboy": "^0.3.1",
"camelcase": "^6.2.0",
@@ -176,7 +175,6 @@
"gitHead": "24621f3934dc77eb23441331040ed13c676ceffd",
"devDependencies": {
"@types/async": "3.2.10",
"@types/atob": "2.1.2",
"@types/body-parser": "1.19.2",
"@types/busboy": "0.3.1",
"@types/cookie-parser": "1.4.2",
@@ -189,7 +187,7 @@
"@types/flat": "5.0.2",
"@types/fs-extra": "9.0.13",
"@types/inquirer": "8.1.3",
"@types/jest": "27.0.3",
"@types/jest": "27.4.1",
"@types/js-yaml": "4.0.5",
"@types/json2csv": "5.0.3",
"@types/jsonwebtoken": "8.5.6",
@@ -215,9 +213,9 @@
"@types/wellknown": "0.5.1",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"jest": "27.3.1",
"jest": "27.5.1",
"knex-mock-client": "1.6.1",
"ts-jest": "27.0.7",
"ts-jest": "27.1.3",
"ts-node-dev": "1.1.8",
"typescript": "4.5.2"
}

View File

@@ -1,6 +0,0 @@
export const cache = {
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(true),
};
export const getCache = jest.fn().mockReturnValue({ cache });

View File

@@ -1,18 +1,20 @@
import { RequestHandler } from 'express';
import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { Accountability } from '@directus/shared/types';
import { NextFunction, Request, Response } from 'express';
import { isEqual } from 'lodash';
import getDatabase from '../database';
import emitter from '../emitter';
import env from '../env';
import { InvalidCredentialsException } from '../exceptions';
import { DirectusTokenPayload } from '../types';
import asyncHandler from '../utils/async-handler';
import { getIPFromReq } from '../utils/get-ip-from-req';
import isDirectusJWT from '../utils/is-directus-jwt';
import { verifyAccessJWT } from '../utils/jwt';
/**
* Verify the passed JWT and assign the user ID and role to `req`
*/
const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
req.accountability = {
export const handler = async (req: Request, res: Response, next: NextFunction) => {
const defaultAccountability: Accountability = {
user: null,
role: null,
admin: false,
@@ -21,21 +23,31 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
userAgent: req.get('user-agent'),
};
const database = getDatabase();
const customAccountability = await emitter.emitFilter(
'authenticate',
defaultAccountability,
{
req,
},
{
database,
schema: null,
accountability: null,
}
);
if (customAccountability && isEqual(customAccountability, defaultAccountability) === false) {
req.accountability = customAccountability;
return next();
}
req.accountability = defaultAccountability;
if (req.token) {
if (isDirectusJWT(req.token)) {
let payload: DirectusTokenPayload;
try {
payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as DirectusTokenPayload;
} catch (err: any) {
if (err instanceof TokenExpiredError) {
throw new InvalidCredentialsException('Token expired.');
} else if (err instanceof JsonWebTokenError) {
throw new InvalidCredentialsException('Token invalid.');
} else {
throw err;
}
}
const payload = verifyAccessJWT(req.token, env.SECRET);
req.accountability.share = payload.share;
req.accountability.share_scope = payload.share_scope;
@@ -44,8 +56,6 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
req.accountability.app = payload.app_access === true || payload.app_access == 1;
} else {
const database = getDatabase();
// Try finding the user with the provided token
const user = await database
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
@@ -69,6 +79,6 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
}
return next();
});
};
export default authenticate;
export default asyncHandler(handler);

View File

@@ -1,34 +1,15 @@
import atob from 'atob';
import logger from '../logger';
import jwt from 'jsonwebtoken';
/**
* Check if a given string conforms to the structure of a JWT
* and whether it is issued by Directus.
*/
export default function isDirectusJWT(string: string): boolean {
const parts = string.split('.');
// JWTs have the structure header.payload.signature
if (parts.length !== 3) return false;
// Check if all segments are base64 encoded
try {
atob(parts[0]);
atob(parts[1]);
atob(parts[2]);
} catch (err: any) {
logger.error(err);
return false;
}
// Check if the header and payload are valid JSON
try {
JSON.parse(atob(parts[0]));
const payload = JSON.parse(atob(parts[1]));
if (payload.iss !== 'directus') return false;
const payload = jwt.decode(string, { json: true });
if (payload?.iss !== 'directus') return false;
return true;
} catch {
return false;
}
return true;
}

29
api/src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,29 @@
import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { DirectusTokenPayload } from '../types';
import { InvalidTokenException, ServiceUnavailableException } from '../exceptions';
export function verifyAccessJWT(token: string, secret: string): DirectusTokenPayload {
let payload;
try {
payload = jwt.verify(token, secret, {
issuer: 'directus',
}) as Record<string, any>;
} catch (err) {
if (err instanceof TokenExpiredError) {
throw new InvalidTokenException('Token expired.');
} else if (err instanceof JsonWebTokenError) {
throw new InvalidTokenException('Token invalid.');
} else {
throw new ServiceUnavailableException(`Couldn't verify token.`, { service: 'jwt' });
}
}
const { id, role, app_access, admin_access, share, share_scope } = payload;
if (role === undefined || app_access === undefined || admin_access === undefined) {
throw new InvalidTokenException('Invalid token payload.');
}
return { id, role, app_access, admin_access, share, share_scope };
}

View File

@@ -1,9 +1,9 @@
import { Command } from 'commander';
import { Extension, HookConfig } from '@directus/shared/types';
import { createCli } from '../../../src/cli/index';
import { createCli } from '../../src/cli/index';
jest.mock('../../../src/env', () => ({
...jest.requireActual('../../../src/env').default,
jest.mock('../../src/env', () => ({
...jest.requireActual('../../src/env').default,
EXTENSIONS_PATH: '',
SERVE_APP: false,
DB_CLIENT: 'pg',

View File

@@ -1,6 +1,6 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import run from '../../../../src/database/migrations/run';
import run from '../../../src/database/migrations/run';
describe('run', () => {
let db: jest.Mocked<Knex>;

View File

@@ -1,76 +0,0 @@
import express from 'express';
import request from 'supertest';
import checkCacheMiddleware from '../../../src/middleware/cache';
jest.mock('../../../src/cache');
jest.mock('../../../src/env', () => ({
CACHE_ENABLED: true,
CACHE_NAMESPACE: 'test',
CACHE_STORE: 'memory',
CACHE_TTL: '5s',
CACHE_CONTROL_S_MAXAGE: true,
}));
const { cache } = jest.requireMock('../../../src/cache');
const env = jest.requireMock('../../../src/env');
const handler = jest.fn((req, res) => res.json({ data: 'Uncached value' }));
const setup = () => express().use(checkCacheMiddleware).all('/items/test', handler);
beforeEach(jest.clearAllMocks);
describe('cache middleware', () => {
test('should return the cached response for a request', async () => {
cache.get.mockResolvedValueOnce({ data: 'Cached value' });
cache.get.mockResolvedValueOnce(new Date().getTime() + 1000 * 60);
const res = await request(setup()).get('/items/test').send();
expect(res.body.data).toBe('Cached value');
expect(res.headers['vary']).toBe('Origin, Cache-Control');
expect(res.headers['cache-control']).toMatch(/public, max-age=\d+, s-maxage=\d+/);
expect(handler).not.toHaveBeenCalled();
});
test('should call the handler when there is no cached value', async () => {
cache.get.mockResolvedValueOnce(undefined);
const res = await request(setup()).get('/items/test').send();
expect(res.body.data).toBe('Uncached value');
expect(cache.get).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledTimes(1);
});
test('should not cache requests then the cache is disabled', async () => {
env.CACHE_ENABLED = false;
const res = await request(setup()).get('/items/test').send();
expect(res.body.data).toBe('Uncached value');
expect(cache.get).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(1);
env.CACHE_ENABLED = true;
});
test('should not use cache when the "Cache-Control" header is set to "no-store"', async () => {
const res = await request(setup()).get('/items/test').set('Cache-Control', 'no-store').send();
expect(res.body.data).toBe('Uncached value');
expect(cache.get).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(1);
});
test('should only cache get requests', async () => {
const app = setup();
await request(app).post('/items/test').send();
await request(app).put('/items/test').send();
await request(app).patch('/items/test').send();
await request(app).delete('/items/test').send();
expect(cache.get).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(4);
});
});

View File

@@ -0,0 +1,217 @@
// @ts-nocheck
import jwt from 'jsonwebtoken';
import getDatabase from '../../src/database';
import emitter from '../../src/emitter';
import env from '../../src/env';
import { InvalidCredentialsException } from '../../src/exceptions';
import { handler } from '../../src/middleware/authenticate';
import '../../src/types/express.d.ts';
jest.mock('../../src/database');
jest.mock('../../src/env', () => ({
SECRET: 'test',
}));
afterEach(() => {
jest.resetAllMocks();
});
test('Short-circuits when authenticate filter is used', async () => {
const req = {
ip: '127.0.0.1',
get: jest.fn(),
};
const res = {};
const next = jest.fn();
const customAccountability = { admin: true };
jest.spyOn(emitter, 'emitFilter').mockResolvedValue(customAccountability);
await handler(req, res, next);
expect(req.accountability).toEqual(customAccountability);
expect(next).toHaveBeenCalledTimes(1);
});
test('Uses default public accountability when no token is given', async () => {
const req = {
ip: '127.0.0.1',
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
};
const res = {};
const next = jest.fn();
jest.spyOn(emitter, 'emitFilter').mockImplementation((_, payload) => payload);
await handler(req, res, next);
expect(req.accountability).toEqual({
user: null,
role: null,
admin: false,
app: false,
ip: '127.0.0.1',
userAgent: 'fake-user-agent',
});
expect(next).toHaveBeenCalledTimes(1);
});
test('Sets accountability to payload contents if valid token is passed', async () => {
const userID = '3fac3c02-607f-4438-8d6e-6b8b25109b52';
const roleID = '38269fc6-6eb6-475a-93cb-479d97f73039';
const share = 'ca0ad005-f4ad-4bfe-b428-419ee8784790';
const shareScope = {
collection: 'articles',
item: 15,
};
const appAccess = true;
const adminAccess = false;
const token = jwt.sign(
{
id: userID,
role: roleID,
app_access: appAccess,
admin_access: adminAccess,
share,
share_scope: shareScope,
},
env.SECRET,
{ issuer: 'directus' }
);
const req = {
ip: '127.0.0.1',
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
token,
};
const res = {};
const next = jest.fn();
await handler(req, res, next);
expect(req.accountability).toEqual({
user: userID,
role: roleID,
app: appAccess,
admin: adminAccess,
share,
share_scope: shareScope,
ip: '127.0.0.1',
userAgent: 'fake-user-agent',
});
expect(next).toHaveBeenCalledTimes(1);
// Test with 1/0 instead or true/false
next.mockClear();
req.token = jwt.sign(
{
id: userID,
role: roleID,
app_access: 1,
admin_access: 0,
share,
share_scope: shareScope,
},
env.SECRET,
{ issuer: 'directus' }
);
await handler(req, res, next);
expect(req.accountability).toEqual({
user: userID,
role: roleID,
app: appAccess,
admin: adminAccess,
share,
share_scope: shareScope,
ip: '127.0.0.1',
userAgent: 'fake-user-agent',
});
expect(next).toHaveBeenCalledTimes(1);
});
test('Throws InvalidCredentialsException when static token is used, but user does not exist', async () => {
jest.mocked(getDatabase).mockReturnValue({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(undefined),
});
const req = {
ip: '127.0.0.1',
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
token: 'static-token',
};
const res = {};
const next = jest.fn();
expect(handler(req, res, next)).rejects.toEqual(new InvalidCredentialsException());
expect(next).toHaveBeenCalledTimes(0);
});
test('Sets accountability to user information when static token is used', async () => {
const req = {
ip: '127.0.0.1',
get: jest.fn((string) => (string === 'user-agent' ? 'fake-user-agent' : null)),
token: 'static-token',
};
const res = {};
const next = jest.fn();
const testUser = { id: 'test-id', role: 'test-role', admin_access: true, app_access: false };
const expectedAccountability = {
user: testUser.id,
role: testUser.role,
app: testUser.app_access,
admin: testUser.admin_access,
ip: '127.0.0.1',
userAgent: 'fake-user-agent',
};
jest.mocked(getDatabase).mockReturnValue({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(testUser),
});
await handler(req, res, next);
expect(req.accountability).toEqual(expectedAccountability);
expect(next).toHaveBeenCalledTimes(1);
// Test for 0 / 1 instead of false / true
next.mockClear();
testUser.admin_access = 1;
testUser.app_access = 0;
await handler(req, res, next);
expect(req.accountability).toEqual(expectedAccountability);
expect(next).toHaveBeenCalledTimes(1);
// Test for "1" / "0" instead of true / false
next.mockClear();
testUser.admin_access = '0';
testUser.app_access = '1';
expectedAccountability.admin = false;
expectedAccountability.app = true;
await handler(req, res, next);
expect(req.accountability).toEqual(expectedAccountability);
expect(next).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,67 @@
import { NextFunction, Request, Response } from 'express';
import extractToken from '../../src/middleware/extract-token';
import '../../src/types/express.d.ts';
let mockRequest: Partial<Request & { token?: string }>;
let mockResponse: Partial<Response>;
const nextFunction: NextFunction = jest.fn();
beforeEach(() => {
mockRequest = {};
mockResponse = {};
jest.clearAllMocks();
});
test('Token from query', () => {
mockRequest = {
query: {
access_token: 'test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Token from Authorization header (capitalized)', () => {
mockRequest = {
headers: {
authorization: 'Bearer test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Token from Authorization header (lowercase)', () => {
mockRequest = {
headers: {
authorization: 'bearer test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Ignore the token if authorization header is too many parts', () => {
mockRequest = {
headers: {
authorization: 'bearer test what another one',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBeNull();
expect(nextFunction).toBeCalledTimes(1);
});
test('Null if no token passed', () => {
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBeNull();
expect(nextFunction).toBeCalledTimes(1);
});

View File

@@ -1,13 +1,13 @@
import knex, { Knex } from 'knex';
import { MockClient, Tracker, getTracker } from 'knex-mock-client';
import { ItemsService } from '../../../src/services';
import { sqlFieldFormatter, sqlFieldList } from '../../__test-utils__/items-utils';
import { systemSchema, userSchema } from '../../__test-utils__/schemas';
import { ItemsService } from '../../src/services';
import { sqlFieldFormatter, sqlFieldList } from '../__test-utils__/items-utils';
import { systemSchema, userSchema } from '../__test-utils__/schemas';
jest.mock('../../../src/database/index', () => {
jest.mock('../../src/database/index', () => {
return { getDatabaseClient: jest.fn().mockReturnValue('postgres') };
});
jest.requireMock('../../../src/database/index');
jest.requireMock('../../src/database/index');
describe('Integration Tests', () => {
let db: jest.Mocked<Knex>;

View File

@@ -1,11 +1,11 @@
import knex, { Knex } from 'knex';
import { MockClient, Tracker, getTracker } from 'knex-mock-client';
import { PayloadService } from '../../../src/services';
import { PayloadService } from '../../src/services';
jest.mock('../../../src/database/index', () => {
jest.mock('../../src/database/index', () => {
return { getDatabaseClient: jest.fn().mockReturnValue('postgres') };
});
jest.requireMock('../../../src/database/index');
jest.requireMock('../../src/database/index');
describe('Integration Tests', () => {
let db: jest.Mocked<Knex>;

View File

@@ -1,69 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import extractToken from '../../../src/middleware/extract-token';
import '../../../src/types/express.d.ts';
describe('Middleware / Extract Token', () => {
let mockRequest: Partial<Request & { token?: string }>;
let mockResponse: Partial<Response>;
const nextFunction: NextFunction = jest.fn();
beforeEach(() => {
mockRequest = {};
mockResponse = {};
jest.clearAllMocks();
});
test('Token from query', () => {
mockRequest = {
query: {
access_token: 'test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Token from Authorization header (capitalized)', () => {
mockRequest = {
headers: {
authorization: 'Bearer test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Token from Authorization header (lowercase)', () => {
mockRequest = {
headers: {
authorization: 'bearer test',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBe('test');
expect(nextFunction).toBeCalledTimes(1);
});
test('Ignore the token if authorization header is too many parts', () => {
mockRequest = {
headers: {
authorization: 'bearer test what another one',
},
};
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBeNull();
expect(nextFunction).toBeCalledTimes(1);
});
test('Null if no token passed', () => {
extractToken(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockRequest.token).toBeNull();
expect(nextFunction).toBeCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
import { Request } from 'express';
import { getCacheKey } from '../../../src/utils/get-cache-key';
import { getCacheKey } from '../../src/utils/get-cache-key';
const restUrl = 'http://localhost/items/example';
const graphQlUrl = 'http://localhost/graphql';

View File

@@ -0,0 +1,25 @@
import isDirectusJWT from '../../src/utils/is-directus-jwt';
import jwt from 'jsonwebtoken';
test('Returns false for non JWT string', () => {
const result = isDirectusJWT('test');
expect(result).toBe(false);
});
test('Returns false for JWTs with text payload', () => {
const token = jwt.sign('plaintext', 'secret');
const result = isDirectusJWT(token);
expect(result).toBe(false);
});
test(`Returns false if token issuer isn't "directus"`, () => {
const token = jwt.sign({ payload: 'content' }, 'secret', { issuer: 'rijk' });
const result = isDirectusJWT(token);
expect(result).toBe(false);
});
test(`Returns true if token is valid JWT and issuer is "directus"`, () => {
const token = jwt.sign({ payload: 'content' }, 'secret', { issuer: 'directus' });
const result = isDirectusJWT(token);
expect(result).toBe(true);
});

View File

@@ -0,0 +1,37 @@
import { verifyAccessJWT } from '../../src/utils/jwt';
import jwt from 'jsonwebtoken';
import { InvalidTokenException, ServiceUnavailableException } from '../../src/exceptions';
import { DirectusTokenPayload } from '../../src/types';
const payload: DirectusTokenPayload = { role: null, app_access: false, admin_access: false };
const secret = 'test-secret';
const options = { issuer: 'directus' };
test('Returns the payload of a correctly signed token', () => {
const token = jwt.sign(payload, secret, options);
const result = verifyAccessJWT(token, secret);
expect(result).toEqual(payload);
});
const InvalidTokenCases = {
'wrong issuer': jwt.sign(payload, secret, { issuer: 'wrong' }),
'wrong secret': jwt.sign(payload, 'wrong-secret', options),
expired: jwt.sign({ ...payload, exp: new Date().getTime() / 1000 - 500 }, secret, options),
'string payload': jwt.sign('illegal payload', secret),
'missing properties in token payload': jwt.sign({ role: null }, secret, options),
};
Object.entries(InvalidTokenCases).forEach(([title, token]) =>
test(`Throws InvalidTokenError - ${title}`, () => {
expect(() => verifyAccessJWT(token, secret)).toThrow(InvalidTokenException);
})
);
test(`Throws ServiceUnavailableException for unexpected error from jsonwebtoken`, () => {
jest.spyOn(jwt, 'verify').mockImplementation(() => {
throw new Error();
});
const token = jwt.sign(payload, secret, options);
expect(() => verifyAccessJWT(token, secret)).toThrow(ServiceUnavailableException);
});

View File

@@ -1,4 +1,4 @@
import { mergePermission } from '../../../src/utils/merge-permissions';
import { mergePermission } from '../../src/utils/merge-permissions';
import { Permission, Filter } from '@directus/shared/types';
const fullFilter = {} as Filter;

View File

@@ -146,20 +146,21 @@ export default ({ schedule }) => {
### Filter Events
| Name | Payload | Meta |
| ----------------------------- | -------------------- | ------------------------------------ |
| `request.not_found` | `false` | `request`, `response` |
| `request.error` | The request errors | -- |
| `database.error` | The database error | `client` |
| `auth.login` | The login payload | `status`, `user`, `provider` |
| `auth.jwt` | The auth token | `status`, `user`, `provider`, `type` |
| `(<collection>.)items.read` | The read item | `collection` |
| `(<collection>.)items.create` | The new item | `collection` |
| `(<collection>.)items.update` | The updated item | `keys`, `collection` |
| `(<collection>.)items.delete` | The keys of the item | `collection` |
| `<system-collection>.create` | The new item | `collection` |
| `<system-collection>.update` | The updated item | `keys`, `collection` |
| `<system-collection>.delete` | The keys of the item | `collection` |
| Name | Payload | Meta |
| ----------------------------- | ------------------------------- | ------------------------------------ |
| `request.not_found` | `false` | `request`, `response` |
| `request.error` | The request errors | -- |
| `database.error` | The database error | `client` |
| `auth.login` | The login payload | `status`, `user`, `provider` |
| `auth.jwt` | The auth token | `status`, `user`, `provider`, `type` |
| `authenticate` | The empty accountability object | `req` |
| `(<collection>.)items.read` | The read item | `collection` |
| `(<collection>.)items.create` | The new item | `collection` |
| `(<collection>.)items.update` | The updated item | `keys`, `collection` |
| `(<collection>.)items.delete` | The keys of the item | `collection` |
| `<system-collection>.create` | The new item | `collection` |
| `<system-collection>.update` | The updated item | `keys`, `collection` |
| `<system-collection>.delete` | The keys of the item | `collection` |
::: tip System Collections

View File

@@ -16,4 +16,11 @@ module.exports = {
'@directus/shared(.*)$': `${__dirname}/packages/shared/src/$1`,
'@directus/specs(.*)$': `${__dirname}/packages/specs/$1`,
},
globals: {
'ts-jest': {
tsconfig: {
sourceMap: true,
},
},
},
};

1718
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@
"devDependencies": {
"@types/dockerode": "3.3.0",
"@types/faker": "5.5.9",
"@types/jest": "27.0.3",
"@types/jest": "27.4.1",
"@types/listr": "0.14.4",
"@types/node": "16.11.9",
"@types/supertest": "2.0.11",
@@ -44,7 +44,7 @@
"eslint-plugin-vue": "7.20.0",
"faker": "5.5.3",
"globby": "11.0.4",
"jest": "27.3.1",
"jest": "27.5.1",
"knex": "0.95.15",
"lerna": "4.0.0",
"lint-staged": "11.2.6",
@@ -65,7 +65,7 @@
"stylelint-scss": "4.0.0",
"supertest": "6.1.6",
"tedious": "13.2.0",
"ts-jest": "27.0.7",
"ts-jest": "27.1.3",
"ts-node": "10.4.0"
},
"simple-git-hooks": {