feat: recursive saveToJWT field support (#3130)

This commit is contained in:
Dan Ribbens
2023-08-08 12:38:44 -04:00
committed by GitHub
parent 8e188cfe61
commit c6e0908076
6 changed files with 261 additions and 25 deletions

View File

@@ -83,7 +83,7 @@ Once enabled, each document that is created within the Collection can be thought
Successfully logging in returns a `JWT` (JSON web token) which is how a user will identify themselves to Payload. By providing this JWT via either an HTTP-only cookie or an `Authorization` header, Payload will automatically identify the user and add its user JWT data to the Express `req`, which is available throughout Payload including within access control, hooks, and more.
You can specify what data gets encoded to the JWT token by setting `saveToJWT` to true in your auth collection fields. If you wish to use a different key other than the field `name`, you can provide it to `saveToJWT` as a string.
You can specify what data gets encoded to the JWT token by setting `saveToJWT` to true in your auth collection fields. If you wish to use a different key other than the field `name`, you can provide it to `saveToJWT` as a string. It is also possible to use `saveToJWT` on fields that are nested in inside groups and tabs. If a group has a `saveToJWT` set it will include the object with all sub-fields in the token. You can set `saveToJWT: false` for any fields you wish to omit. If a field inside a group has `saveToJWT` set, but the group does not, the field will be included at the top level of the token.
<Banner type="success">
<strong>Tip:</strong><br/>

View File

@@ -1,7 +1,99 @@
/* eslint-disable no-param-reassign */
import { User } from '..';
import { CollectionConfig } from '../../collections/config/types';
import { Field, fieldAffectsData, fieldHasSubFields } from '../../fields/config/types';
import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../fields/config/types';
type TraverseFieldsArgs = {
fields: (Field | TabAsField)[]
data: Record<string, unknown>
result: Record<string, unknown>
}
const traverseFields = ({
// parent,
fields,
data,
result,
}: TraverseFieldsArgs) => {
fields.forEach((field) => {
switch (field.type) {
case 'row':
case 'collapsible': {
traverseFields({
fields: field.fields,
data,
result,
});
break;
}
case 'group': {
let targetResult;
if (typeof field.saveToJWT === 'string') {
targetResult = field.saveToJWT;
result[field.saveToJWT] = data[field.name];
} else if (field.saveToJWT) {
targetResult = field.name;
result[field.name] = data[field.name];
}
const groupData: Record<string, unknown> = data[field.name] as Record<string, unknown>;
const groupResult = (targetResult ? result[targetResult] : result) as Record<string, unknown>;
traverseFields({
fields: field.fields,
data: groupData,
result: groupResult,
});
break;
}
case 'tabs': {
traverseFields({
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
data,
result,
});
break;
}
case 'tab': {
if (tabHasName(field)) {
let targetResult;
if (typeof field.saveToJWT === 'string') {
targetResult = field.saveToJWT;
result[field.saveToJWT] = data[field.name];
} else if (field.saveToJWT) {
targetResult = field.name;
result[field.name] = data[field.name];
}
const tabData: Record<string, unknown> = data[field.name] as Record<string, unknown>;
const tabResult = (targetResult ? result[targetResult] : result) as Record<string, unknown>;
traverseFields({
fields: field.fields,
data: tabData,
result: tabResult,
});
} else {
traverseFields({
fields: field.fields,
data,
result,
});
}
break;
}
default:
if (fieldAffectsData(field)) {
if (field.saveToJWT) {
if (typeof field.saveToJWT === 'string') {
result[field.saveToJWT] = data[field.name];
delete result[field.name];
} else {
result[field.name] = data[field.name] as Record<string, unknown>;
}
} else if (field.saveToJWT === false) {
delete result[field.name];
}
}
}
});
return result;
};
export const getFieldsToSign = (args: {
collectionConfig: CollectionConfig,
user: User
@@ -13,28 +105,17 @@ export const getFieldsToSign = (args: {
email,
} = args;
return collectionConfig.fields.reduce((signedFields, field: Field) => {
const result = {
...signedFields,
};
// get subfields from non-named fields like rows
if (!fieldAffectsData(field) && fieldHasSubFields(field)) {
field.fields.forEach((subField) => {
if (fieldAffectsData(subField) && subField.saveToJWT) {
result[typeof subField.saveToJWT === 'string' ? subField.saveToJWT : subField.name] = user[subField.name];
}
});
}
if (fieldAffectsData(field) && field.saveToJWT) {
result[typeof field.saveToJWT === 'string' ? field.saveToJWT : field.name] = user[field.name];
}
return result;
}, {
const result: Record<string, unknown> = {
email,
id: user.id,
collection: collectionConfig.slug,
};
traverseFields({
fields: collectionConfig.fields,
data: user,
result,
});
return result;
};

View File

@@ -228,6 +228,10 @@ const tab = baseField.keys({
.when('localized', { is: joi.exist(), then: joi.required() }),
localized: joi.boolean(),
interfaceName: joi.string().when('name', { not: joi.exist(), then: joi.forbidden() }),
saveToJWT: joi.alternatives().try(
joi.boolean(),
joi.string(),
),
label: joi.alternatives().try(
joi.string(),
joi.object().pattern(joi.string(), [joi.string()]),

View File

@@ -234,6 +234,7 @@ export type TabsAdmin = Omit<Admin, 'description'>;
type TabBase = Omit<FieldBase, 'required' | 'validation'> & {
fields: Field[]
saveToJWT?: boolean | string
description?: Description
interfaceName?: string
}
@@ -256,7 +257,7 @@ export type UnnamedTab = Omit<TabBase, 'name'> & {
export type Tab = NamedTab | UnnamedTab
export type TabsField = Omit<FieldBase, 'admin' | 'name' | 'localized'> & {
export type TabsField = Omit<FieldBase, 'admin' | 'name' | 'localized' | 'saveToJWT'> & {
type: 'tabs';
tabs: Tab[]
admin?: TabsAdmin

View File

@@ -52,6 +52,88 @@ export default buildConfigWithDefaults({
defaultValue: namedSaveToJWTValue,
saveToJWT: saveToJWTKey,
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'liftedSaveToJWT',
type: 'text',
saveToJWT: 'x-lifted-from-group',
defaultValue: 'lifted from group',
},
],
},
{
name: 'groupSaveToJWT',
type: 'group',
saveToJWT: 'x-group',
fields: [
{
name: 'saveToJWTString',
type: 'text',
saveToJWT: 'x-test',
defaultValue: 'nested property',
},
{
name: 'saveToJWTFalse',
type: 'text',
saveToJWT: false,
defaultValue: 'nested property',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'saveToJWTTab',
saveToJWT: true,
fields: [
{
name: 'test',
type: 'text',
saveToJWT: 'x-field',
defaultValue: 'yes',
},
],
},
{
name: 'tabSaveToJWTString',
saveToJWT: 'tab-test',
fields: [
{
name: 'includedByDefault',
type: 'text',
defaultValue: 'yes',
},
],
},
{
label: 'No Name',
fields: [
{
name: 'tabLiftedSaveToJWT',
type: 'text',
saveToJWT: true,
defaultValue: 'lifted from unnamed tab',
},
{
name: 'unnamedTabSaveToJWTString',
type: 'text',
saveToJWT: 'x-tab-field',
defaultValue: 'text',
},
{
name: 'unnamedTabSaveToJWTFalse',
type: 'text',
saveToJWT: false,
defaultValue: 'false',
},
],
},
],
},
{
name: 'custom',
label: 'Custom',

View File

@@ -1,14 +1,17 @@
import mongoose from 'mongoose';
import jwtDecode from 'jwt-decode';
import { GraphQLClient } from 'graphql-request';
import payload from '../../src';
import { initPayloadTest } from '../helpers/configHelpers';
import { namedSaveToJWTValue, saveToJWTKey, slug } from './config';
import { devUser } from '../credentials';
import type { User } from '../../src/auth';
import configPromise from '../collections-graphql/config';
require('isomorphic-fetch');
let apiUrl;
let client: GraphQLClient;
const headers = {
'Content-Type': 'application/json',
@@ -20,6 +23,9 @@ describe('Auth', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
apiUrl = `${serverURL}/api`;
const config = await configPromise;
const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`;
client = new GraphQLClient(url);
});
afterAll(async () => {
@@ -28,7 +34,50 @@ describe('Auth', () => {
await payload.mongoMemoryServer.stop();
});
describe('admin user', () => {
describe('GraphQL - admin user', () => {
let token;
let user;
beforeAll(async () => {
// language=graphQL
const query = `mutation {
loginUser(email: "${devUser.email}", password: "${devUser.password}") {
token
user {
id
email
}
}
}`;
const response = await client.request(query);
user = response.loginUser.user;
token = response.loginUser.token;
});
it('should login', async () => {
expect(user.id).toBeDefined();
expect(user.email).toEqual(devUser.email);
expect(token).toBeDefined();
});
it('should have fields saved to JWT', async () => {
const decoded = jwtDecode<User>(token);
const {
email: jwtEmail,
collection,
roles,
iat,
exp,
} = decoded;
expect(jwtEmail).toBeDefined();
expect(collection).toEqual('users');
expect(Array.isArray(roles)).toBeTruthy();
expect(iat).toBeDefined();
expect(exp).toBeDefined();
});
});
describe('REST - admin user', () => {
beforeAll(async () => {
await fetch(`${apiUrl}/${slug}/first-register`, {
body: JSON.stringify({
@@ -103,20 +152,39 @@ describe('Auth', () => {
});
it('should have fields saved to JWT', async () => {
const decoded = jwtDecode<User>(token);
const {
email: jwtEmail,
collection,
roles,
[saveToJWTKey]: customJWTPropertyKey,
'x-lifted-from-group': liftedFromGroup,
'x-tab-field': unnamedTabSaveToJWTString,
tabLiftedSaveToJWT,
unnamedTabSaveToJWTFalse,
iat,
exp,
} = jwtDecode<User>(token);
} = decoded;
const group = decoded['x-group'] as Record<string, unknown>;
const tab = decoded.saveToJWTTab as Record<string, unknown>;
const tabString = decoded['tab-test'] as Record<string, unknown>;
expect(jwtEmail).toBeDefined();
expect(collection).toEqual('users');
expect(collection).toEqual('users');
expect(Array.isArray(roles)).toBeTruthy();
// 'x-custom-jwt-property-name': 'namedSaveToJWT value'
expect(customJWTPropertyKey).toEqual(namedSaveToJWTValue);
expect(group).toBeDefined();
expect(group['x-test']).toEqual('nested property');
expect(group.saveToJWTFalse).toBeUndefined();
expect(liftedFromGroup).toEqual('lifted from group');
expect(tabLiftedSaveToJWT).toEqual('lifted from unnamed tab');
expect(tab['x-field']).toEqual('yes');
expect(tabString.includedByDefault).toEqual('yes');
expect(unnamedTabSaveToJWTString).toEqual('text');
expect(unnamedTabSaveToJWTFalse).toBeUndefined();
expect(iat).toBeDefined();
expect(exp).toBeDefined();
});