From ee403675f65c4138d4821085d5f8a585a99fc903 Mon Sep 17 00:00:00 2001 From: Markus Tacker Date: Thu, 26 Aug 2021 00:37:56 +0200 Subject: [PATCH] feat: update auth context and implement cookie renewal --- db/migrations/.gitkeep | 0 docs/authentication.md | 21 ++++ schema.graphql | 4 +- src/apolloServer.ts | 19 ++-- src/authenticateRequest.ts | 101 +++++++++++------- src/getProfile.ts | 37 ------- .../input-validation/Contact.ts | 0 .../input-validation/Location.ts | 0 .../input-validation/country-codes.ts | 0 .../input-validation/currency-codes.ts | 0 .../errorsToProblemDetails.ts | 0 .../input-validation/idInputSchema.ts | 0 src/input-validation/trimAll.ts | 8 ++ src/{resolvers => }/input-validation/types.ts | 0 .../validateWithJSONSchema.ts | 4 +- src/login.ts | 35 ------ src/models/shipment_export.ts | 4 +- src/models/user_account.ts | 31 ++++-- src/registerUser.ts | 23 ---- src/resolvers/group.ts | 33 +++--- src/resolvers/line_items.ts | 18 ++-- src/resolvers/offer.ts | 16 +-- src/resolvers/offer_authorization.ts | 4 +- src/resolvers/pallet.ts | 6 +- src/resolvers/shipment.ts | 20 ++-- src/resolvers/shipment_exports.ts | 17 ++- src/routes/login.ts | 54 ++++++++++ src/routes/me.ts | 17 +++ src/routes/me/cookie.ts | 28 +++++ src/routes/register.ts | 56 ++++++++++ src/server.ts | 16 ++- src/testServer.ts | 26 ++--- ...unt_api.test.ts => authentication.test.ts} | 64 ++++++++--- src/tests/groups_api.test.ts | 20 ++-- src/tests/helpers/index.ts | 2 +- .../errorsToProblemDetails.test.ts | 9 +- src/tests/input-validation/types.test.ts | 4 +- .../validateWithJSONSchema.test.ts | 2 +- src/tests/line_items_api.test.ts | 8 +- src/tests/offers_api.test.ts | 8 +- src/tests/pallets_api.test.ts | 8 +- src/tests/shipment_exports_api.test.ts | 4 +- src/tests/trimAll.test.ts | 18 ++++ 43 files changed, 472 insertions(+), 273 deletions(-) create mode 100644 db/migrations/.gitkeep create mode 100644 docs/authentication.md delete mode 100644 src/getProfile.ts rename src/{resolvers => }/input-validation/Contact.ts (100%) rename src/{resolvers => }/input-validation/Location.ts (100%) rename src/{resolvers => }/input-validation/country-codes.ts (100%) rename src/{resolvers => }/input-validation/currency-codes.ts (100%) rename src/{resolvers => }/input-validation/errorsToProblemDetails.ts (100%) rename src/{resolvers => }/input-validation/idInputSchema.ts (100%) create mode 100644 src/input-validation/trimAll.ts rename src/{resolvers => }/input-validation/types.ts (100%) rename src/{resolvers => }/input-validation/validateWithJSONSchema.ts (95%) delete mode 100644 src/login.ts delete mode 100644 src/registerUser.ts create mode 100644 src/routes/login.ts create mode 100644 src/routes/me.ts create mode 100644 src/routes/me/cookie.ts create mode 100644 src/routes/register.ts rename src/tests/{user_account_api.test.ts => authentication.test.ts} (58%) create mode 100644 src/tests/trimAll.test.ts diff --git a/db/migrations/.gitkeep b/db/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 000000000..ea55b315f --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,21 @@ +# Authentication + +The backend authenticates requests using signed cookies so they can contain user information so that it does not have to be fetched for every request. + +The cookie contains the user's id. + +Cookies are sent [`secure` and `HttpOnly`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) when users register their account, or when they login using username and password. + +Cookies expire after 30 minutes and the client is responsible to renew cookies by calling the `GET /me/cookie` endpoint before they expire. + +When renewing cookies the server will re-check if the user still exists and if they haven't changed their password. For this a hash of the user's password hash, email, username, and id will be generated and included in the cookie. If any of these properties changes, the cookie cannot be renewed and the user has to log-in again. + +## Admin permissions + +Admin permission are granted via the `isAdmin` flag on the `UserAccount`. + +## Configuration + +These environment variables control the authentication: + +- `COOKIE_SECRET`: sets the secret used to sign cookies, default value is a random string diff --git a/schema.graphql b/schema.graphql index bfce79d44..581a0f233 100644 --- a/schema.graphql +++ b/schema.graphql @@ -197,8 +197,8 @@ enum GroupType { type UserProfile { id: Int! isAdmin: Boolean! - groupId: Int - username: String! + name: String! + group: Group } enum OfferStatus { diff --git a/src/apolloServer.ts b/src/apolloServer.ts index ada12009d..e8b8e82db 100644 --- a/src/apolloServer.ts +++ b/src/apolloServer.ts @@ -4,7 +4,11 @@ import { AuthenticationError, } from 'apollo-server-express' import depthLimit from 'graphql-depth-limit' -import { AuthContext, authenticateWithToken } from './authenticateRequest' +import { + AuthContext, + authCookieName, + decodeAuthCookie, +} from './authenticateRequest' import generateCsv, { CsvRow } from './generateCsv' import resolvers from './resolvers' import typeDefs from './typeDefs' @@ -23,13 +27,14 @@ export const serverConfig: ApolloServerExpressConfig = { resolvers, validationRules: [depthLimit(7)], async context({ req }): Promise { - const auth = await authenticateWithToken(req.signedCookies.token) - - if (!('userAccount' in auth)) { - throw new AuthenticationError(auth.message) + try { + return { + auth: decodeAuthCookie(req.signedCookies[authCookieName]), + services: { generateCsv }, + } + } catch (err) { + throw new AuthenticationError(err.message) } - - return { auth: auth as AuthContext, services: { generateCsv } } }, } diff --git a/src/authenticateRequest.ts b/src/authenticateRequest.ts index afda45c29..7beccfb28 100644 --- a/src/authenticateRequest.ts +++ b/src/authenticateRequest.ts @@ -1,54 +1,77 @@ +import * as crypto from 'crypto' +import { CookieOptions } from 'express' import { Strategy as CookieStrategy } from 'passport-cookie' import UserAccount from './models/user_account' -const fakeAccount = UserAccount.build({ - username: '', - token: '', - passwordHash: '', -}) +type AuthCookiePayload = { + /** user ID */ + i: number + /** user is admin */ + a: boolean + /** user hash */ + c: string +} export type AuthContext = { - userAccount: UserAccount + userId: number isAdmin: boolean + userHash: string } -export type ErrorInfo = { - message: string -} - -export const fakeAdminAuth: AuthContext = { - userAccount: fakeAccount, - isAdmin: true, -} - -export const fakeUserAuth: AuthContext = { - userAccount: fakeAccount, - isAdmin: false, -} - -export const authenticateWithToken = async ( - token: string, -): Promise => { - try { - const userAccount = await UserAccount.findOne({ - where: { token }, - }) - if (userAccount === null) return { message: 'User not found for token.' } - return { userAccount, isAdmin: false } - } catch (err) { - return err - } -} +export const userHash = (user: UserAccount): string => + crypto + .createHash('sha1') + .update(`${user.id}:${user.username}:${user.passwordHash}`) + .digest('hex') -export const authTokenCookieName = 'token' +export const authCookieName = 'auth' export const cookieAuthStrategy = new CookieStrategy( { - cookieName: authTokenCookieName, + cookieName: authCookieName, signed: true, }, - async (token: string, done: any) => { - const res = await authenticateWithToken(token) - if ('userAccount' in res) return done(null, res) - return done(null, false, res) + async (value: string, done: any) => { + try { + return done(null, decodeAuthCookie(value)) + } catch (error) { + return done( + null, + false, + new Error(`Failed to decode cookie payload: ${error.message}!`), + ) + } }, ) + +export const authCookie = ( + user: UserAccount, + lifetimeInMinutes: number = 30, +): [string, string, CookieOptions] => [ + authCookieName, + JSON.stringify({ + i: user.id, + a: false, + c: userHash(user), + }), + { + signed: true, + secure: true, + httpOnly: true, + expires: new Date(Date.now() + lifetimeInMinutes * 60 * 1000), + }, +] + +export const userToAuthContext = (user: UserAccount): AuthContext => ({ + isAdmin: user.isAdmin, + userId: user.id, + userHash: userHash(user), +}) + +export const decodeAuthCookie = (value: string): AuthContext => { + const { + i: userId, + a: isAdmin, + c: userHash, + } = JSON.parse(value) as AuthCookiePayload + return { userId, isAdmin, userHash } +} diff --git a/src/getProfile.ts b/src/getProfile.ts deleted file mode 100644 index e99ea7760..000000000 --- a/src/getProfile.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Maybe } from '@graphql-tools/utils' -import { Request, Response } from 'express' -import { AuthContext } from './authenticateRequest' -import Group from './models/group' -import UserAccount from './models/user_account' - -const sendProfile = ( - response: Response, - userAccount: UserAccount, - isAdmin = false, - groupId?: number, -) => response.json(userAccount.asProfile(groupId, isAdmin)).end() - -const getProfile = async (request: Request, response: Response) => { - const authContext = request.user as AuthContext - let groupForUser: Maybe - if (!authContext.isAdmin) { - // Note: this assumes that there is only 1 captain per group, where in - // reality there are no restrictions on the number of groups with the same - // captain. For now, we've agreed that this is okay, but we probably need - // to solidify some restrictions later on. - // See https://github.com/distributeaid/shipment-tracker/issues/133 - groupForUser = await Group.findOne({ - where: { captainId: authContext.userAccount.id }, - attributes: ['id'], - }) - } - - sendProfile( - response, - authContext.userAccount, - authContext.isAdmin, - groupForUser?.id, - ) -} - -export default getProfile diff --git a/src/resolvers/input-validation/Contact.ts b/src/input-validation/Contact.ts similarity index 100% rename from src/resolvers/input-validation/Contact.ts rename to src/input-validation/Contact.ts diff --git a/src/resolvers/input-validation/Location.ts b/src/input-validation/Location.ts similarity index 100% rename from src/resolvers/input-validation/Location.ts rename to src/input-validation/Location.ts diff --git a/src/resolvers/input-validation/country-codes.ts b/src/input-validation/country-codes.ts similarity index 100% rename from src/resolvers/input-validation/country-codes.ts rename to src/input-validation/country-codes.ts diff --git a/src/resolvers/input-validation/currency-codes.ts b/src/input-validation/currency-codes.ts similarity index 100% rename from src/resolvers/input-validation/currency-codes.ts rename to src/input-validation/currency-codes.ts diff --git a/src/resolvers/input-validation/errorsToProblemDetails.ts b/src/input-validation/errorsToProblemDetails.ts similarity index 100% rename from src/resolvers/input-validation/errorsToProblemDetails.ts rename to src/input-validation/errorsToProblemDetails.ts diff --git a/src/resolvers/input-validation/idInputSchema.ts b/src/input-validation/idInputSchema.ts similarity index 100% rename from src/resolvers/input-validation/idInputSchema.ts rename to src/input-validation/idInputSchema.ts diff --git a/src/input-validation/trimAll.ts b/src/input-validation/trimAll.ts new file mode 100644 index 000000000..7912901c0 --- /dev/null +++ b/src/input-validation/trimAll.ts @@ -0,0 +1,8 @@ +export const trimAll = (o: Record): Record => + Object.entries(o).reduce( + (r, [k, v]) => ({ + ...r, + [k]: v.trim(), + }), + {}, + ) diff --git a/src/resolvers/input-validation/types.ts b/src/input-validation/types.ts similarity index 100% rename from src/resolvers/input-validation/types.ts rename to src/input-validation/types.ts diff --git a/src/resolvers/input-validation/validateWithJSONSchema.ts b/src/input-validation/validateWithJSONSchema.ts similarity index 95% rename from src/resolvers/input-validation/validateWithJSONSchema.ts rename to src/input-validation/validateWithJSONSchema.ts index edb1a6d04..4e8d091c9 100644 --- a/src/resolvers/input-validation/validateWithJSONSchema.ts +++ b/src/input-validation/validateWithJSONSchema.ts @@ -16,9 +16,7 @@ ajv.addKeyword('modifier') export const validateWithJSONSchema = >( schema: T, -): (( - value: Record, -) => +): ((value: Record) => | { errors: ErrorObject[] } diff --git a/src/login.ts b/src/login.ts deleted file mode 100644 index f5af5b364..000000000 --- a/src/login.ts +++ /dev/null @@ -1,35 +0,0 @@ -import bcrypt from 'bcrypt' -import { Request, Response } from 'express' -import { v4 } from 'uuid' -import { authTokenCookieName } from './authenticateRequest' -import UserAccount from './models/user_account' - -// FIXME: Add input validation -const login = - (penaltySeconds = 10) => - async (request: Request, response: Response) => { - const user = await UserAccount.findOne({ - where: { - username: request.body.username, - }, - }) - if (user === null) { - // Penalize - await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) - return response.status(401).end() - } - if (!bcrypt.compareSync(request.body.password, user.passwordHash)) { - // Penalize - await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) - return response.status(401).end() - } - // Generate new token - const token = v4() - await user.update({ token }) - response - .status(204) - .cookie(authTokenCookieName, token, { signed: true }) - .end() - } - -export default login diff --git a/src/models/shipment_export.ts b/src/models/shipment_export.ts index cfc6cb1bf..5f7c86954 100644 --- a/src/models/shipment_export.ts +++ b/src/models/shipment_export.ts @@ -55,8 +55,8 @@ export default class ShipmentExport extends Model { downloadPath: `/shipment-exports/${this.id}`, createdBy: { id: this.userAccountId, - isAdmin: true, - username: this.userAccount.username, + isAdmin: this.userAccount.isAdmin, + name: this.userAccount.name, }, createdAt: this.createdAt, } diff --git a/src/models/user_account.ts b/src/models/user_account.ts index 88feddfcb..c597583c0 100644 --- a/src/models/user_account.ts +++ b/src/models/user_account.ts @@ -1,6 +1,8 @@ +import { Maybe } from 'graphql/jsutils/Maybe' import { Column, CreatedAt, + Default, Model, Table, Unique, @@ -8,12 +10,14 @@ import { } from 'sequelize-typescript' import { Optional } from 'sequelize/types' import { UserProfile } from '../server-internal-types' +import Group from './group' export interface UserAccountAttributes { id: number username: string passwordHash: string - token: string + isAdmin?: boolean + name: string } export interface UserAccountCreationAttributes @@ -36,7 +40,11 @@ export default class UserAccount extends Model< public passwordHash!: string @Column - public token!: string + public name!: string + + @Default(false) + @Column + public isAdmin!: boolean @CreatedAt @Column @@ -46,12 +54,23 @@ export default class UserAccount extends Model< @Column public readonly updatedAt!: Date - public asProfile(groupId?: number, isAdmin = false): UserProfile { + public async asPublicProfile(): Promise { + let groupForUser: Maybe + if (!this.isAdmin) { + // Note: this assumes that there is only 1 captain per group, where in + // reality there are no restrictions on the number of groups with the same + // captain. For now, we've agreed that this is okay, but we probably need + // to solidify some restrictions later on. + // See https://github.com/distributeaid/shipment-tracker/issues/133 + groupForUser = await Group.findOne({ + where: { captainId: this.id }, + }) + } return { id: this.id, - username: this.username, - isAdmin, - groupId, + isAdmin: this.isAdmin, + name: this.name, + group: groupForUser, } } } diff --git a/src/registerUser.ts b/src/registerUser.ts deleted file mode 100644 index a4ad72f6c..000000000 --- a/src/registerUser.ts +++ /dev/null @@ -1,23 +0,0 @@ -import bcrypt from 'bcrypt' -import { Request, Response } from 'express' -import { v4 } from 'uuid' -import { authTokenCookieName } from './authenticateRequest' -import UserAccount from './models/user_account' - -// FIXME: Add input validation -const registerUser = - (saltRounds = 10) => - async (request: Request, response: Response) => { - const token = v4() - await UserAccount.create({ - passwordHash: bcrypt.hashSync(request.body.password, saltRounds), - token, - username: request.body.username, - }) - response - .status(202) - .cookie(authTokenCookieName, token, { signed: true }) - .end() - } - -export default registerUser diff --git a/src/resolvers/group.ts b/src/resolvers/group.ts index b0ccb7f61..c4f7b7362 100644 --- a/src/resolvers/group.ts +++ b/src/resolvers/group.ts @@ -2,6 +2,16 @@ import { Type } from '@sinclair/typebox' import { ApolloError, ForbiddenError, UserInputError } from 'apollo-server' import { strict as assert } from 'assert' import { FindOptions, Op } from 'sequelize' +import { Contact } from '../input-validation/Contact' +import { validateIdInput } from '../input-validation/idInputSchema' +import { Location } from '../input-validation/Location' +import { + ID, + NonEmptyShortString, + OptionalValueOrUnset, + URI, +} from '../input-validation/types' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' import Group, { GroupAttributes } from '../models/group' import UserAccount from '../models/user_account' import { @@ -9,16 +19,6 @@ import { MutationResolvers, QueryResolvers, } from '../server-internal-types' -import { Contact } from './input-validation/Contact' -import { validateIdInput } from './input-validation/idInputSchema' -import { Location } from './input-validation/Location' -import { - ID, - NonEmptyShortString, - OptionalValueOrUnset, - URI, -} from './input-validation/types' -import { validateWithJSONSchema } from './input-validation/validateWithJSONSchema' // Group query resolvers @@ -95,7 +95,7 @@ const addGroup: MutationResolvers['addGroup'] = async ( context, ) => { assert.ok( - typeof context.auth.userAccount.id === 'number', + typeof context.auth.userId === 'number', 'Current user id should be set', ) const valid = validateAddGroupInput(input) @@ -114,7 +114,7 @@ const addGroup: MutationResolvers['addGroup'] = async ( // Non-admins are only allowed to create a single group if (!context.auth.isAdmin) { const numGroupsForUser = await Group.count({ - where: { captainId: context.auth.userAccount.id }, + where: { captainId: context.auth.userId }, }) if (numGroupsForUser > 0) { @@ -124,7 +124,7 @@ const addGroup: MutationResolvers['addGroup'] = async ( return Group.create({ ...valid.value, - captainId: context.auth.userAccount.id, + captainId: context.auth.userId, }) } @@ -150,7 +150,7 @@ const updateGroup: MutationResolvers['updateGroup'] = async ( context, ) => { assert.ok( - typeof context.auth.userAccount.id === 'number', + typeof context.auth.userId === 'number', 'Current user id should be set', ) const valid = validateUpdateGroupInput(input) @@ -164,10 +164,7 @@ const updateGroup: MutationResolvers['updateGroup'] = async ( throw new UserInputError(`Group ${id} does not exist`) } - if ( - group.captainId !== context.auth.userAccount.id && - !context.auth.isAdmin - ) { + if (group.captainId !== context.auth.userId && !context.auth.isAdmin) { throw new ForbiddenError('Not permitted to update group') } diff --git a/src/resolvers/line_items.ts b/src/resolvers/line_items.ts index 527c91da6..23e5bb927 100644 --- a/src/resolvers/line_items.ts +++ b/src/resolvers/line_items.ts @@ -2,6 +2,15 @@ import { Static, Type } from '@sinclair/typebox' import { ApolloError, UserInputError } from 'apollo-server' import { isEqual } from 'lodash' import { AuthenticatedContext } from '../apolloServer' +import { validateIdInput } from '../input-validation/idInputSchema' +import { + DateTime, + ID, + NonEmptyShortString, + PositiveInteger, + URI, +} from '../input-validation/types' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' import Group from '../models/group' import LineItem, { LineItemAttributes } from '../models/line_item' import Offer from '../models/offer' @@ -17,15 +26,6 @@ import { QueryResolvers, } from '../server-internal-types' import getPalletWithParentAssociations from './getPalletWithParentAssociations' -import { validateIdInput } from './input-validation/idInputSchema' -import { - DateTime, - ID, - NonEmptyShortString, - PositiveInteger, - URI, -} from './input-validation/types' -import { validateWithJSONSchema } from './input-validation/validateWithJSONSchema' import { authorizeOfferMutation, authorizeOfferQuery, diff --git a/src/resolvers/offer.ts b/src/resolvers/offer.ts index 2db584a34..e525343e0 100644 --- a/src/resolvers/offer.ts +++ b/src/resolvers/offer.ts @@ -1,6 +1,10 @@ import { Type } from '@sinclair/typebox' import { ForbiddenError, UserInputError } from 'apollo-server' import { strict as assert } from 'assert' +import { Contact } from '../input-validation/Contact' +import { validateIdInput } from '../input-validation/idInputSchema' +import { ID, URI } from '../input-validation/types' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' import Group from '../models/group' import Offer, { OfferAttributes } from '../models/offer' import Pallet from '../models/pallet' @@ -12,10 +16,6 @@ import { QueryResolvers, ShipmentStatus, } from '../server-internal-types' -import { Contact } from './input-validation/Contact' -import { validateIdInput } from './input-validation/idInputSchema' -import { ID, URI } from './input-validation/types' -import { validateWithJSONSchema } from './input-validation/validateWithJSONSchema' import { authorizeOfferMutation, authorizeOfferQuery, @@ -43,7 +43,7 @@ const addOffer: MutationResolvers['addOffer'] = async ( context, ) => { assert.ok( - typeof context.auth.userAccount.id === 'number', + typeof context.auth.userId === 'number', 'Current user id should be set', ) const valid = validateAddOfferInput(input) @@ -62,11 +62,11 @@ const addOffer: MutationResolvers['addOffer'] = async ( const sendingGroup = await sendingGroupPromise if ( - sendingGroup?.captainId !== context.auth.userAccount.id && + sendingGroup?.captainId !== context.auth.userId && !context.auth.isAdmin ) { throw new ForbiddenError( - `User ${context.auth.userAccount.id} not permitted to create offer for group ${valid.value.sendingGroupId}`, + `User ${context.auth.userId} not permitted to create offer for group ${valid.value.sendingGroupId}`, ) } @@ -188,7 +188,7 @@ const listOffers: QueryResolvers['listOffers'] = async ( } const groupsPromise = Group.findAll({ - where: { captainId: context.auth.userAccount.id }, + where: { captainId: context.auth.userId }, }) const shipment = await Shipment.findByPk(shipmentId) diff --git a/src/resolvers/offer_authorization.ts b/src/resolvers/offer_authorization.ts index a56385745..2bd2fecbe 100644 --- a/src/resolvers/offer_authorization.ts +++ b/src/resolvers/offer_authorization.ts @@ -33,11 +33,11 @@ const assertAccountIsCaptainOrAdmin = ( context: AuthenticatedContext, ): void => { assert.ok( - typeof context.auth.userAccount.id === 'number', + typeof context.auth.userId === 'number', 'Current user id should be set', ) if ( - offer.sendingGroup.captainId !== context.auth.userAccount.id && + offer.sendingGroup.captainId !== context.auth.userId && !context.auth.isAdmin ) { throw new ForbiddenError('Forbidden to access this offer') diff --git a/src/resolvers/pallet.ts b/src/resolvers/pallet.ts index bce46a20f..cebbc77b2 100644 --- a/src/resolvers/pallet.ts +++ b/src/resolvers/pallet.ts @@ -1,5 +1,8 @@ import { Type } from '@sinclair/typebox' import { ApolloError, UserInputError } from 'apollo-server' +import { validateIdInput } from '../input-validation/idInputSchema' +import { ID } from '../input-validation/types' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' import LineItem from '../models/line_item' import Offer from '../models/offer' import Pallet, { PalletAttributes } from '../models/pallet' @@ -11,9 +14,6 @@ import { QueryResolvers, } from '../server-internal-types' import getPalletWithParentAssociations from './getPalletWithParentAssociations' -import { validateIdInput } from './input-validation/idInputSchema' -import { ID } from './input-validation/types' -import { validateWithJSONSchema } from './input-validation/validateWithJSONSchema' import { authorizeOfferMutation, authorizeOfferQuery, diff --git a/src/resolvers/shipment.ts b/src/resolvers/shipment.ts index 320bf660a..c81f8a106 100644 --- a/src/resolvers/shipment.ts +++ b/src/resolvers/shipment.ts @@ -1,11 +1,20 @@ import { Type } from '@sinclair/typebox' import { ApolloError, ForbiddenError, UserInputError } from 'apollo-server' import { isEqual, xor } from 'lodash' +import { validateIdInput } from '../input-validation/idInputSchema' +import { + CurrentYearOrGreater, + ID, + MonthIndexStartingAt1, + Pricing, +} from '../input-validation/types' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' import Group from '../models/group' import Shipment, { ShipmentAttributes } from '../models/shipment' import ShipmentExport from '../models/shipment_export' import ShipmentReceivingHub from '../models/shipment_receiving_hub' import ShipmentSendingHub from '../models/shipment_sending_hub' +import UserAccount from '../models/user_account' import { MutationResolvers, QueryResolvers, @@ -13,14 +22,6 @@ import { ShipmentStatus, ShippingRoute, } from '../server-internal-types' -import { validateIdInput } from './input-validation/idInputSchema' -import { - CurrentYearOrGreater, - ID, - MonthIndexStartingAt1, - Pricing, -} from './input-validation/types' -import { validateWithJSONSchema } from './input-validation/validateWithJSONSchema' const arraysOverlap = (a: unknown[], b: unknown[]): boolean => xor(a, b).length === 0 @@ -482,7 +483,7 @@ const receivingHubs: ShipmentResolvers['receivingHubs'] = async (parent) => { const shipmentExports: ShipmentResolvers['exports'] = async ( parent, _, - { auth: isAdmin }, + { auth: { isAdmin } }, ) => { if (!isAdmin) { throw new ForbiddenError('Must be admin to query shipment exports') @@ -490,6 +491,7 @@ const shipmentExports: ShipmentResolvers['exports'] = async ( return ShipmentExport.findAll({ where: { shipmentId: parent.id }, + include: [UserAccount], }).then((shipmentExports) => shipmentExports.map((shipmentExport) => shipmentExport.toWireFormat()), ) diff --git a/src/resolvers/shipment_exports.ts b/src/resolvers/shipment_exports.ts index 49b872dab..f6d8c5c7b 100644 --- a/src/resolvers/shipment_exports.ts +++ b/src/resolvers/shipment_exports.ts @@ -5,8 +5,11 @@ import Offer from '../models/offer' import Pallet from '../models/pallet' import Shipment from '../models/shipment' import ShipmentExport from '../models/shipment_export' +import UserAccount from '../models/user_account' import { MutationResolvers, QueryResolvers } from '../server-internal-types' +const include = [UserAccount] + const exportShipment: MutationResolvers['exportShipment'] = async ( _, { shipmentId }, @@ -59,10 +62,14 @@ const exportShipment: MutationResolvers['exportShipment'] = async ( const exportRecord = await ShipmentExport.create({ contentsCsv: csv, shipmentId, - userAccountId: auth.userAccount.id, + userAccountId: auth.userId, }) - return exportRecord.toWireFormat() + return ( + (await ShipmentExport.findByPk(exportRecord.id, { + include, + })) as ShipmentExport + ).toWireFormat() } export const HEADER_ROW = [ @@ -119,9 +126,9 @@ const listShipmentExports: QueryResolvers['listShipmentExports'] = async ( throw new ForbiddenError('Must be admin') } - return ( - await ShipmentExport.findAll({ where: { shipmentId } }) - ).map((shipmentExport: ShipmentExport) => shipmentExport.toWireFormat()) + return (await ShipmentExport.findAll({ where: { shipmentId }, include })).map( + (shipmentExport: ShipmentExport) => shipmentExport.toWireFormat(), + ) } export { exportShipment, listShipmentExports } diff --git a/src/routes/login.ts b/src/routes/login.ts new file mode 100644 index 000000000..176fda0f8 --- /dev/null +++ b/src/routes/login.ts @@ -0,0 +1,54 @@ +import { Type } from '@sinclair/typebox' +import { UserInputError } from 'apollo-server-express' +import bcrypt from 'bcrypt' +import { Request, Response } from 'express' +import { authCookie } from '../authenticateRequest' +import { trimAll } from '../input-validation/trimAll' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' +import UserAccount from '../models/user_account' +import { passwordInput, usernameInput } from './register' + +const loginInput = Type.Object( + { + username: usernameInput, + password: passwordInput, + }, + { additionalProperties: false }, +) + +const validateLoginInput = validateWithJSONSchema(loginInput) + +const login = + (penaltySeconds = 10) => + async (request: Request, response: Response) => { + const valid = validateLoginInput(trimAll(request.body)) + if ('errors' in valid) { + return response + .status(400) + .json(new UserInputError('Login input invalid', valid.errors)) + .end() + } + + const user = await UserAccount.findOne({ + where: { + username: valid.value.username, + }, + }) + if (user === null) { + // Penalize + await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) + return response.status(401).end() + } + if (!bcrypt.compareSync(valid.value.password, user.passwordHash)) { + // Penalize + await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) + return response.status(401).end() + } + // Generate new token + response + .status(204) + .cookie(...authCookie(user)) + .end() + } + +export default login diff --git a/src/routes/me.ts b/src/routes/me.ts new file mode 100644 index 000000000..d18d6f970 --- /dev/null +++ b/src/routes/me.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express' +import { AuthContext } from '../authenticateRequest' +import UserAccount from '../models/user_account' + +const getProfile = async (request: Request, response: Response) => { + const authContext = request.user as AuthContext + const user = await UserAccount.findByPk(authContext.userId) + if (user === null) return response.send(404).end() + return response + .json({ + username: user.username, + ...(await user.asPublicProfile()), + }) + .end() +} + +export default getProfile diff --git a/src/routes/me/cookie.ts b/src/routes/me/cookie.ts new file mode 100644 index 000000000..ffa92a0e6 --- /dev/null +++ b/src/routes/me/cookie.ts @@ -0,0 +1,28 @@ +import { Request, Response } from 'express' +import { AuthContext, authCookie, userHash } from '../../authenticateRequest' +import UserAccount from '../../models/user_account' + +const renewCookie = + (penaltySeconds = 10) => + async (request: Request, response: Response) => { + const authContext = request.user as AuthContext + console.log(authContext) + const user = await UserAccount.findByPk(authContext.userId) + if (user === null) { + // Penalize + await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) + return response.status(401).end() + } + if (userHash(user) !== authContext.userHash) { + // Penalize + await new Promise((resolve) => setTimeout(resolve, penaltySeconds * 1000)) + return response.status(401).end() + } + // Generate new token + response + .status(204) + .cookie(...authCookie(user)) + .end() + } + +export default renewCookie diff --git a/src/routes/register.ts b/src/routes/register.ts new file mode 100644 index 000000000..edcd13aa4 --- /dev/null +++ b/src/routes/register.ts @@ -0,0 +1,56 @@ +import { Type } from '@sinclair/typebox' +import { UserInputError } from 'apollo-server-express' +import bcrypt from 'bcrypt' +import { Request, Response } from 'express' +import { authCookie } from '../authenticateRequest' +import { trimAll } from '../input-validation/trimAll' +import { validateWithJSONSchema } from '../input-validation/validateWithJSONSchema' +import UserAccount from '../models/user_account' + +export const usernameInput = Type.String({ + pattern: '^[a-z0-9._-]{1,255}$', + title: 'Username', +}) + +export const passwordInput = Type.String({ + pattern: '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$', + title: 'Password', +}) + +const registerUserInput = Type.Object( + { + username: usernameInput, + name: Type.String({ minLength: 1, maxLength: 255 }), + password: passwordInput, + }, + { additionalProperties: false }, +) + +const validateRegisterUserInput = validateWithJSONSchema(registerUserInput) + +const registerUser = + (saltRounds = 10) => + async (request: Request, response: Response) => { + const valid = validateRegisterUserInput(trimAll(request.body)) + if ('errors' in valid) { + return response + .status(400) + .json( + new UserInputError('User registration input invalid', valid.errors), + ) + .end() + } + + const user = await UserAccount.create({ + passwordHash: bcrypt.hashSync(valid.value.password, saltRounds), + username: valid.value.username, + name: valid.value.name, + }) + + return response + .status(202) + .cookie(...authCookie(user)) + .end() + } + +export default registerUser diff --git a/src/server.ts b/src/server.ts index 012d464d6..340584bb7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,22 +15,28 @@ import './loadEnv' import './sequelize' import apolloServer from './apolloServer' -import getProfile from './getProfile' +import getProfile from './routes/me' import getAllFilesSync from './getAllFilesSync' import sendShipmentExportCsv from './sendShipmentExportCsv' import { cookieAuthStrategy } from './authenticateRequest' -import registerUser from './registerUser' -import login from './login' +import registerUser from './routes/register' +import login from './routes/login' +import { v4 } from 'uuid' +import renewCookie from './routes/me/cookie' const app = express() -app.use(cookieParser(process.env.COOKIE_SECRET ?? 'cookie-secret')) +/** + * @see ./docs/authentication.md + */ +app.use(cookieParser(process.env.COOKIE_SECRET ?? v4())) app.use(json()) const cookieAuth = passport.authenticate('cookie', { session: false }) passport.use(cookieAuthStrategy) app.get('/me', cookieAuth, getProfile) +app.get('/me/cookie', cookieAuth, renewCookie()) app.get('/login', login()) -app.get('/user', registerUser) +app.get('/register', registerUser()) app.get('/shipment-exports/:id', cookieAuth, sendShipmentExportCsv) app.use( diff --git a/src/testServer.ts b/src/testServer.ts index be582f8d7..ad9483e64 100644 --- a/src/testServer.ts +++ b/src/testServer.ts @@ -1,7 +1,7 @@ import { ApolloServer, ApolloServerExpressConfig } from 'apollo-server-express' import { merge } from 'lodash' import { serverConfig, Services } from './apolloServer' -import { fakeAdminAuth, fakeUserAuth } from './authenticateRequest' +import { userToAuthContext } from './authenticateRequest' import { CsvRow } from './generateCsv' import UserAccount from './models/user_account' @@ -29,14 +29,15 @@ export const makeTestServer = async ( overrides: Partial = {}, ): Promise => { if (overrides.context == null) { - const userAccount = await UserAccount.create({ - username: 'user-id', + const user = await UserAccount.create({ + isAdmin: false, + name: 'User', + username: 'user', passwordHash: '', - token: '', }) overrides.context = () => ({ - auth: { ...fakeUserAuth, userAccount }, + auth: userToAuthContext(user), services: { generateCsv: makeFakeGenerateCsvFn().generateCsv, }, @@ -53,21 +54,22 @@ export const makeAdminTestServer = async ( export const makeAdminTestServerWithServices = async ( overrides: Partial = {}, ) => { - const userAccount = await UserAccount.create({ - username: 'admin-auth0-id', - passwordHash: '', - token: '', - }) - const fakeGenrateCsv = makeFakeGenerateCsvFn() const services = { ...fakeGenrateCsv, } + const admin = await UserAccount.create({ + isAdmin: true, + name: 'Admin', + username: 'admin', + passwordHash: '', + }) + const testServer = await makeTestServer({ context: () => ({ - auth: { ...fakeAdminAuth, userAccount }, + auth: userToAuthContext(admin), services, }), ...overrides, diff --git a/src/tests/user_account_api.test.ts b/src/tests/authentication.test.ts similarity index 58% rename from src/tests/user_account_api.test.ts rename to src/tests/authentication.test.ts index 84f0db810..b261fb772 100644 --- a/src/tests/user_account_api.test.ts +++ b/src/tests/authentication.test.ts @@ -5,19 +5,20 @@ import { createServer, Server } from 'http' import passport from 'passport' import request, { SuperTest, Test } from 'supertest' import '../sequelize' -import { authTokenCookieName, cookieAuthStrategy } from '../authenticateRequest' -import getProfile from '../getProfile' +import { authCookieName, cookieAuthStrategy } from '../authenticateRequest' +import getProfile from '../routes/me' import cookieParser from 'cookie-parser' import { json } from 'body-parser' -import registerUser from '../registerUser' -import login from '../login' +import registerUser from '../routes/register' +import login from '../routes/login' +import renewCookie from '../routes/me/cookie' -jest.setTimeout(60 * 1000) +jest.setTimeout(15 * 1000) const cookieAuth = passport.authenticate('cookie', { session: false }) passport.use(cookieAuthStrategy) -const tokenCookieRx = new RegExp(`${authTokenCookieName}=([^;]+); Path=/`) +const tokenCookieRx = new RegExp(`${authCookieName}=([^;]+);`, 'i') const generateUsername = async () => (await randomWords({ numWords: 3 })).join('-') @@ -28,7 +29,7 @@ describe('User account API', () => { let r: SuperTest let username: string let password: string - let token: string + let authCookie: string beforeAll(async () => { username = await generateUsername() password = 'y{uugBmw"9,?=L_' @@ -36,8 +37,9 @@ describe('User account API', () => { app.use(cookieParser(process.env.COOKIE_SECRET ?? 'cookie-secret')) app.use(json()) app.get('/me', cookieAuth, getProfile) - app.post('/user', registerUser(1)) + app.post('/register', registerUser(1)) app.post('/login', login(0)) + app.get('/me/cookie', cookieAuth, renewCookie(0)) httpServer = createServer(app) await new Promise((resolve) => httpServer.listen(8888, '127.0.0.1', undefined, resolve), @@ -50,24 +52,51 @@ describe('User account API', () => { describe('/register', () => { it('should register a new user account', async () => { const res = await r - .post('/user') + .post('/register') .set('Accept', 'application/json') .set('Content-type', 'application/json; charset=utf-8') .send({ username, password, + name: 'Alex', }) .expect(202) .expect('set-cookie', tokenCookieRx) - token = tokenCookieRx.exec(res.header['set-cookie'])?.[1] as string + const cookieInfo = (res.header['set-cookie'][0] as string) + .split('; ') + .map((s) => s.split('=', 2)) + .reduce( + (c, [k, v], i) => + i === 0 + ? { + [decodeURIComponent(k)]: v ? decodeURIComponent(v) : true, + } + : { + ...c, + options: { + ...c.options, + [decodeURIComponent(k)]: v ? decodeURIComponent(v) : true, + }, + }, + {} as Record, + ) + + expect(cookieInfo[authCookieName]).toBeDefined() + expect(cookieInfo.options).toMatchObject({ Path: '/', HttpOnly: true }) + const expiresIn = + new Date(cookieInfo.options.Expires).getTime() - Date.now() + expect(expiresIn).toBeLessThan(30 * 60 * 1000) + expect(expiresIn).toBeGreaterThan(0) + + authCookie = tokenCookieRx.exec(res.header['set-cookie'])?.[1] as string }) }) describe('/me', () => { it('should return the user account of the current user', async () => { const res = await r .get('/me') - .set('Cookie', [`${authTokenCookieName}=${token}`]) + .set('Cookie', [`${authCookieName}=${authCookie}`]) .set('Accept', 'application/json') .send() .expect(200) @@ -80,9 +109,16 @@ describe('User account API', () => { it('should deny request for unknown token', async () => r .get('/me') - .set('Cookie', [`${authTokenCookieName}=foo`]) + .set('Cookie', [`${authCookieName}=foo`]) .send() .expect(401)) + describe('/cookie', () => { + it('should send a new cookie', () => + r + .get('/me/cookie') + .set('Cookie', [`${authCookieName}=${authCookie}`]) + .expect(204)) + }) }) describe('/login', () => { it('should return a token on login', () => @@ -99,7 +135,7 @@ describe('User account API', () => { .post('/login') .send({ username, - password: 'foo', + password: "Y @@ -107,7 +143,7 @@ describe('User account API', () => { .post('/login') .send({ username: 'foo', - password: 'foo', + password: "Y { // Create test servers captain = await UserAccount.create({ - username: 'captain-id', + username: 'captain', passwordHash: '', - token: '', + name: 'Captain A', }) newCaptain = await UserAccount.create({ - username: 'new-captain-id', + username: 'new-captain', passwordHash: '', - token: '', + name: 'New Captain', }) testServer = await makeTestServer({ - context: () => ({ auth: { ...fakeUserAuth, userAccount: captain } }), + context: () => ({ auth: userToAuthContext(captain) }), }) adminTestServer = await makeAdminTestServer() }) @@ -279,18 +279,18 @@ describe('Groups API', () => { beforeAll(async () => { captain1 = await UserAccount.create({ username: captain1Name, + name: captain1Name, passwordHash: '', - token: '', }) captain2 = await UserAccount.create({ username: captain2Name, + name: captain2Name, passwordHash: '', - token: '', }) daCaptain = await UserAccount.create({ username: daCaptainName, + name: daCaptainName, passwordHash: '', - token: '', }) sendingGroup1 = await Group.create({ name: sendingGroup1Name, @@ -323,7 +323,7 @@ describe('Groups API', () => { ...commonGroupData, }) testServer = await makeTestServer({ - context: () => ({ auth: { ...fakeUserAuth, userAccount: captain1 } }), + context: () => ({ auth: userToAuthContext(captain1) }), }) }) diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index 13c8d13d8..3a8f625cf 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -20,7 +20,7 @@ async function createGroup( const groupCaptain = await UserAccount.create({ username: `fake-auth-id-${fakeusername++}`, passwordHash: '', - token: '', + name: 'Captain', }) captainId = groupCaptain.id } diff --git a/src/tests/input-validation/errorsToProblemDetails.test.ts b/src/tests/input-validation/errorsToProblemDetails.test.ts index f3523259c..eb5f55327 100644 --- a/src/tests/input-validation/errorsToProblemDetails.test.ts +++ b/src/tests/input-validation/errorsToProblemDetails.test.ts @@ -1,11 +1,8 @@ import { Type } from '@sinclair/typebox' import { ErrorObject } from 'ajv' -import { errorsToProblemDetails } from '../../resolvers/input-validation/errorsToProblemDetails' -import { - NonEmptyShortString, - URI, -} from '../../resolvers/input-validation/types' -import { validateWithJSONSchema } from '../../resolvers/input-validation/validateWithJSONSchema' +import { errorsToProblemDetails } from '../../input-validation/errorsToProblemDetails' +import { NonEmptyShortString, URI } from '../../input-validation/types' +import { validateWithJSONSchema } from '../../input-validation/validateWithJSONSchema' describe('errorsToProblemDetails() should turn validation errors into Problem Details for HTTP APIs (RFC7807)', () => { it('should format an unknown property in the top-level object', () => { diff --git a/src/tests/input-validation/types.test.ts b/src/tests/input-validation/types.test.ts index 101728e65..ecd8e9bc2 100644 --- a/src/tests/input-validation/types.test.ts +++ b/src/tests/input-validation/types.test.ts @@ -7,8 +7,8 @@ import { TwoLetterCountryCode, URI, ValueOrUnset, -} from '../../resolvers/input-validation/types' -import { validateWithJSONSchema } from '../../resolvers/input-validation/validateWithJSONSchema' +} from '../../input-validation/types' +import { validateWithJSONSchema } from '../../input-validation/validateWithJSONSchema' describe('input validation types', () => { describe('validate country code input', () => { diff --git a/src/tests/input-validation/validateWithJSONSchema.test.ts b/src/tests/input-validation/validateWithJSONSchema.test.ts index 15581c5a5..a281c2847 100644 --- a/src/tests/input-validation/validateWithJSONSchema.test.ts +++ b/src/tests/input-validation/validateWithJSONSchema.test.ts @@ -1,5 +1,5 @@ import { Type } from '@sinclair/typebox' -import { validateWithJSONSchema } from '../../resolvers/input-validation/validateWithJSONSchema' +import { validateWithJSONSchema } from '../../input-validation/validateWithJSONSchema' describe('validateWithJSONSchema', () => { it('should validate input against a JSON schema', () => { diff --git a/src/tests/line_items_api.test.ts b/src/tests/line_items_api.test.ts index 2e1040f31..2ce641c99 100644 --- a/src/tests/line_items_api.test.ts +++ b/src/tests/line_items_api.test.ts @@ -1,6 +1,6 @@ import { ApolloServer } from 'apollo-server-express' import gql from 'graphql-tag' -import { fakeUserAuth } from '../authenticateRequest' +import { userToAuthContext } from '../authenticateRequest' import Group from '../models/group' import LineItem from '../models/line_item' import Offer from '../models/offer' @@ -37,13 +37,13 @@ describe('LineItems API', () => { await sequelize.sync({ force: true }) captain = await UserAccount.create({ - username: 'captain-id', + username: 'captain', passwordHash: '', - token: '', + name: 'Captain', }) captainTestServer = await makeTestServer({ - context: () => ({ auth: { ...fakeUserAuth, userAccount: captain } }), + context: () => ({ auth: userToAuthContext(captain) }), }) adminTestServer = await makeAdminTestServer() otherUserTestServer = await makeTestServer() diff --git a/src/tests/offers_api.test.ts b/src/tests/offers_api.test.ts index 461c25d40..81c207008 100644 --- a/src/tests/offers_api.test.ts +++ b/src/tests/offers_api.test.ts @@ -1,6 +1,6 @@ import { ApolloServer } from 'apollo-server-express' import gql from 'graphql-tag' -import { fakeUserAuth } from '../authenticateRequest' +import { userToAuthContext } from '../authenticateRequest' import Group from '../models/group' import Offer from '../models/offer' import Shipment from '../models/shipment' @@ -34,13 +34,13 @@ describe('Offers API', () => { await Shipment.truncate({ cascade: true, force: true }) captain = await UserAccount.create({ - username: 'captain-id', + username: 'captain', passwordHash: '', - token: '', + name: 'Captain', }) captainTestServer = await makeTestServer({ - context: () => ({ auth: { ...fakeUserAuth, userAccount: captain } }), + context: () => ({ auth: userToAuthContext(captain) }), }) otherUserTestServer = await makeTestServer() adminTestServer = await makeAdminTestServer() diff --git a/src/tests/pallets_api.test.ts b/src/tests/pallets_api.test.ts index e09054d27..9906902e4 100644 --- a/src/tests/pallets_api.test.ts +++ b/src/tests/pallets_api.test.ts @@ -1,6 +1,6 @@ import { ApolloServer } from 'apollo-server-express' import gql from 'graphql-tag' -import { fakeUserAuth } from '../authenticateRequest' +import { userToAuthContext } from '../authenticateRequest' import Group from '../models/group' import LineItem from '../models/line_item' import Offer from '../models/offer' @@ -40,13 +40,13 @@ describe('Pallets API', () => { await Pallet.truncate({ cascade: true, force: true }) captain = await UserAccount.create({ - username: 'captain-id', + username: 'captain', passwordHash: '', - token: '', + name: 'Captain', }) captainTestServer = await makeTestServer({ - context: () => ({ auth: { ...fakeUserAuth, userAccount: captain } }), + context: () => ({ auth: userToAuthContext(captain) }), }) adminTestServer = await makeAdminTestServer() otherUserTestServer = await makeTestServer() diff --git a/src/tests/shipment_exports_api.test.ts b/src/tests/shipment_exports_api.test.ts index 78730329d..054ddf973 100644 --- a/src/tests/shipment_exports_api.test.ts +++ b/src/tests/shipment_exports_api.test.ts @@ -38,9 +38,9 @@ describe('ShipmentExports API', () => { await sequelize.sync({ force: true }) captain = await UserAccount.create({ - username: 'captain-id', + username: 'captain', passwordHash: '', - token: '', + name: 'Captain', }) const serverWithContext = await makeAdminTestServerWithServices() diff --git a/src/tests/trimAll.test.ts b/src/tests/trimAll.test.ts new file mode 100644 index 000000000..c306468f0 --- /dev/null +++ b/src/tests/trimAll.test.ts @@ -0,0 +1,18 @@ +import { trimAll } from '../input-validation/trimAll' + +describe('trimAll', () => { + it('should trim all whitespace in an object', () => + expect( + trimAll({ + foo: ' bar ', + trimEnd: 'bar ', + trimStart: ' bar', + keepSpace: ' foo bar ', + }), + ).toMatchObject({ + foo: 'bar', + trimEnd: 'bar', + trimStart: 'bar', + keepSpace: 'foo bar', + })) +})