From c6e09080767dad2ab8128ba330b2b344bb25ac6f Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 8 Aug 2023 12:38:44 -0400 Subject: [PATCH] feat: recursive saveToJWT field support (#3130) --- docs/authentication/overview.mdx | 2 +- src/auth/operations/getFieldsToSign.ts | 123 ++++++++++++++++++++----- src/fields/config/schema.ts | 4 + src/fields/config/types.ts | 3 +- test/auth/config.ts | 82 +++++++++++++++++ test/auth/int.spec.ts | 72 ++++++++++++++- 6 files changed, 261 insertions(+), 25 deletions(-) diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index 32bedf00ce..486c9ab3b6 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -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. Tip:
diff --git a/src/auth/operations/getFieldsToSign.ts b/src/auth/operations/getFieldsToSign.ts index 83ad020bd0..c1ab3d1ac3 100644 --- a/src/auth/operations/getFieldsToSign.ts +++ b/src/auth/operations/getFieldsToSign.ts @@ -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 + result: Record +} +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 = data[field.name] as Record; + const groupResult = (targetResult ? result[targetResult] : result) as Record; + 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 = data[field.name] as Record; + const tabResult = (targetResult ? result[targetResult] : result) as Record; + 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; + } + } 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 = { email, id: user.id, collection: collectionConfig.slug, + }; + + traverseFields({ + fields: collectionConfig.fields, + data: user, + result, }); + + return result; }; diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index a0135aff34..0ab85aec6f 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -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()]), diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index edbc0b6e8b..b09699b206 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -234,6 +234,7 @@ export type TabsAdmin = Omit; type TabBase = Omit & { fields: Field[] + saveToJWT?: boolean | string description?: Description interfaceName?: string } @@ -256,7 +257,7 @@ export type UnnamedTab = Omit & { export type Tab = NamedTab | UnnamedTab -export type TabsField = Omit & { +export type TabsField = Omit & { type: 'tabs'; tabs: Tab[] admin?: TabsAdmin diff --git a/test/auth/config.ts b/test/auth/config.ts index c465afff28..4588670f63 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -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', diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index af866e4d61..75877094a2 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -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(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(token); const { email: jwtEmail, collection, roles, [saveToJWTKey]: customJWTPropertyKey, + 'x-lifted-from-group': liftedFromGroup, + 'x-tab-field': unnamedTabSaveToJWTString, + tabLiftedSaveToJWT, + unnamedTabSaveToJWTFalse, iat, exp, - } = jwtDecode(token); + } = decoded; + + const group = decoded['x-group'] as Record; + const tab = decoded.saveToJWTTab as Record; + const tabString = decoded['tab-test'] as Record; 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(); });