Skip to content

Commit

Permalink
Merge branch 'main' into fix-19414
Browse files Browse the repository at this point in the history
  • Loading branch information
rijkvanzanten committed Aug 17, 2023
2 parents c0ae5ff + d3de4d4 commit 28f57de
Show file tree
Hide file tree
Showing 24 changed files with 1,340 additions and 191 deletions.
6 changes: 6 additions & 0 deletions .changeset/cold-pens-develop.md
@@ -0,0 +1,6 @@
---
"@directus/app": minor
"@directus/api": minor
---

Introduced new JSON Web Token (JWT) operation to Flows
5 changes: 5 additions & 0 deletions .changeset/silent-poets-cover.md
@@ -0,0 +1,5 @@
---
'@directus/api': patch
---

Fixed issue where type of value coming from a default value might be wrong
5 changes: 5 additions & 0 deletions .changeset/tasty-rocks-travel.md
@@ -0,0 +1,5 @@
---
"@directus/specs": minor
---

Added missing "update/delete multiple" endpoints to OpenAPI specs
2 changes: 1 addition & 1 deletion api/package.json
Expand Up @@ -123,7 +123,7 @@
"js-yaml": "4.1.0",
"js2xmlparser": "5.0.0",
"json2csv": "5.0.7",
"jsonwebtoken": "9.0.0",
"jsonwebtoken": "9.0.1",
"keyv": "4.5.2",
"knex": "2.4.2",
"ldapjs": "2.3.3",
Expand Down
206 changes: 206 additions & 0 deletions api/src/operations/json-web-token/index.test.ts
@@ -0,0 +1,206 @@
import jwt from 'jsonwebtoken';
import { beforeEach, expect, test, vi } from 'vitest';

import config from './index.js';

beforeEach(() => {
vi.spyOn(jwt, 'sign');
vi.spyOn(jwt, 'verify');
vi.spyOn(jwt, 'decode');
});

const secret = 'some-secret';
const payload = { abc: 123 };

test('sign: should error when missing secret', async () => {
const params = {
operation: 'sign',
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.sign)).not.toHaveBeenCalled();

expect.assertions(2);
});

test('sign: should error when missing payload', async () => {
const params = {
operation: 'sign',
secret,
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.sign)).not.toHaveBeenCalled();

expect.assertions(2);
});

test('sign: returns the JWT', async () => {
const params = {
operation: 'sign',
secret,
payload,
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.sign)).toHaveBeenCalledWith(params.payload, params.secret, undefined);
expect(jwt.decode(result as string)).toMatchObject(params.payload);
});

test('sign: options can be set', async () => {
const params = {
operation: 'sign',
secret,
payload,
options: { issuer: 'directus' },
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.sign)).toHaveBeenCalledWith(params.payload, params.secret, params.options);
expect(jwt.decode(result as string)).toMatchObject({ ...params.payload, iss: params.options.issuer });
});

test('verify: should error when missing secret', async () => {
const params = {
operation: 'verify',
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.verify)).not.toHaveBeenCalled();

expect.assertions(2);
});

test('verify: should error when missing token', async () => {
const params = {
operation: 'verify',
secret,
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.verify)).not.toHaveBeenCalled();

expect.assertions(2);
});

test('verify: should error with incorrect secret', async () => {
const params = {
operation: 'verify',
secret: 'invalid-secret',
token: jwt.sign(payload, secret),
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.verify)).toHaveBeenCalledWith(params.token, params.secret, undefined);

expect.assertions(2);
});

test('verify: returns the payload', async () => {
const params = {
operation: 'verify',
secret,
token: jwt.sign(payload, secret),
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.verify)).toHaveBeenCalledWith(params.token, params.secret, undefined);
expect(result).toMatchObject(payload);
});

test('verify: options can be set', async () => {
const params = {
operation: 'verify',
secret,
token: jwt.sign(payload, secret),
options: {
complete: true,
},
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.verify)).toHaveBeenCalledWith(params.token, params.secret, params.options);

expect(result).toMatchObject({
header: { alg: 'HS256' },
payload,
signature: params.token.split('.')[2],
});
});

test('decode: should error when missing token', async () => {
const params = {
operation: 'decode',
};

try {
await config.handler(params, {} as any);
} catch (err) {
expect(err).toBeDefined();
}

expect(vi.mocked(jwt.decode)).not.toHaveBeenCalled();

expect.assertions(2);
});

test('decode: returns the payload', async () => {
const params = {
operation: 'decode',
token: jwt.sign(payload, secret),
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.decode)).toHaveBeenCalledWith(params.token, undefined);
expect(result).toMatchObject(payload);
});

test('decode: options can be set', async () => {
const params = {
operation: 'decode',
token: jwt.sign(payload, secret),
options: {
complete: true,
},
};

const result = await config.handler(params, {} as any);

expect(vi.mocked(jwt.decode)).toHaveBeenCalledWith(params.token, params.options);

expect(result).toMatchObject({
header: { alg: 'HS256' },
payload,
signature: params.token.split('.')[2],
});
});
45 changes: 45 additions & 0 deletions api/src/operations/json-web-token/index.ts
@@ -0,0 +1,45 @@
import { defineOperationApi, optionToObject, optionToString } from '@directus/utils';
import jwt from 'jsonwebtoken';

type Options = {
operation: string;
payload?: Record<string, any> | string;
token?: string;
secret?: jwt.Secret;
options?: any;
};

export default defineOperationApi<Options>({
id: 'json-web-token',

handler: async ({ operation, payload, token, secret, options }) => {
if (operation === 'sign') {
if (!payload) throw new Error('Undefined JSON Web Token payload');
if (!secret) throw new Error('Undefined JSON Web Token secret');

const payloadObject: any = optionToObject(payload);
const secretString = optionToString(secret);
const optionsObject = optionToObject(options);

return jwt.sign(payloadObject, secretString, optionsObject);
} else if (operation === 'verify') {
if (!token) throw new Error('Undefined JSON Web Token token');
if (!secret) throw new Error('Undefined JSON Web Token secret');

const tokenString = optionToString(token);
const secretString = optionToString(secret);
const optionsObject = optionToObject(options);

return jwt.verify(tokenString, secretString, optionsObject);
} else if (operation === 'decode') {
if (!token) throw new Error('Undefined JSON Web Token token');

const tokenString = optionToString(token);
const optionsObject = optionToObject(options);

return jwt.decode(tokenString, optionsObject);
}

throw new Error('Undefined "Operation" for JSON Web Token');
},
});
7 changes: 5 additions & 2 deletions api/src/services/fields.ts
Expand Up @@ -83,7 +83,10 @@ export class FieldsService {

const columns = (await this.schemaInspector.columnInfo(collection)).map((column) => ({
...column,
default_value: getDefaultValue(column),
default_value: getDefaultValue(
column,
fields.find((field) => field.collection === column.table && field.field === column.name)
),
}));

const columnsWithSystem = columns.map((column) => {
Expand Down Expand Up @@ -228,7 +231,7 @@ export class FieldsService {
const columnWithCastDefaultValue = column
? {
...column,
default_value: getDefaultValue(column),
default_value: getDefaultValue(column, fieldInfo),
}
: null;

Expand Down
5 changes: 3 additions & 2 deletions api/src/services/specifications.ts
Expand Up @@ -249,6 +249,7 @@ class OASSpecsService implements SpecificationSubService {
},
responses: {
'200': {
description: 'Successful request',
content:
method === 'delete'
? undefined
Expand Down Expand Up @@ -487,9 +488,9 @@ class OASSpecsService implements SpecificationSubService {
{
type: 'string',
},
relatedTags.map((tag) => ({
...(relatedTags.map((tag) => ({
$ref: `#/components/schemas/${tag.name}`,
})) as any,
})) as any),
],
};
}
Expand Down
6 changes: 4 additions & 2 deletions api/src/utils/get-default-value.ts
Expand Up @@ -4,11 +4,13 @@ import type { Column } from '@directus/schema';
import env from '../env.js';
import logger from '../logger.js';
import getLocalType from './get-local-type.js';
import type { FieldMeta } from '@directus/types';

export default function getDefaultValue(
column: SchemaOverview[string]['columns'][string] | Column
column: SchemaOverview[string]['columns'][string] | Column,
field?: { special?: FieldMeta['special'] }
): string | boolean | number | Record<string, any> | any[] | null {
const type = getLocalType(column);
const type = getLocalType(column, field);

const defaultValue = column.default_value ?? null;
if (defaultValue === null) return null;
Expand Down
14 changes: 14 additions & 0 deletions app/src/lang/translations/en-US.yaml
Expand Up @@ -2174,6 +2174,20 @@ operations:
key: IDs
payload: Payload
query: Query
json-web-token:
name: JSON Web Token (JWT)
description: Sign and verify JSON Web Tokens
operation: Operation
secret: Secret
secret_placeholder: Enter a secret...
payload: Payload
token: Token
token_placeholder: eyJhbGciOi......
options: Options
options_placeholder: Refer to https://www.npmjs.com/package/jsonwebtoken#usage for the available options
sign: Sign Token
verify: Verify Token
decode: Decode Token
log:
name: Log to Console
description: Output something to the console
Expand Down

0 comments on commit 28f57de

Please sign in to comment.