diff --git a/CHANGELOG.md b/CHANGELOG.md index b093a66..98c5827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- add directive to validate auth token for some operations + ## [1.37.3] - 2023-12-07 ### Fixed diff --git a/graphql/schema.graphql b/graphql/schema.graphql index bc4c7fd..1d91f1a 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -3,7 +3,6 @@ type Query { @cacheControl(scope: PRIVATE) @settings(settingsType: "workspace") @withSender - @auditAccess getRole(id: ID!): Role @cacheControl(scope: PRIVATE, maxAge: SHORT) @@ -14,7 +13,6 @@ type Query { @settings(settingsType: "workspace") @withSession @withSender - @auditAccess getFeaturesByModule(module: String!): Feature @settings(settingsType: "workspace") @@ -24,31 +22,28 @@ type Query { @settings(settingsType: "workspace") @cacheControl(scope: PRIVATE, maxAge: SHORT) @withSender - @auditAccess - getUser(id: ID!): User @cacheControl(scope: PRIVATE) + getUser(id: ID!): User @cacheControl(scope: PRIVATE) @checkUserAccess getB2BUser(id: ID!): User @cacheControl(scope: PRIVATE) - checkCustomerSchema: Boolean - @cacheControl(scope: PRIVATE) - @withSender - @auditAccess + checkCustomerSchema: Boolean @cacheControl(scope: PRIVATE) @withSender getUserByEmail(email: String!): [User] @cacheControl(scope: PRIVATE) @withSender - @auditAccess + @checkUserAccess listAllUsers: [User] @cacheControl(scope: PRIVATE, maxAge: SHORT) @withSender - @auditAccess + @checkUserAccess listUsers(organizationId: ID, costCenterId: ID, roleId: ID): [User] @cacheControl(scope: PRIVATE, maxAge: SHORT) @deprecated( reason: "This query is deprecated, use listUsersPaginated query instead." ) + @checkUserAccess listUsersPaginated( organizationId: ID @@ -59,7 +54,9 @@ type Query { search: String sortOrder: String sortedBy: String - ): UserPagination @cacheControl(scope: PRIVATE, maxAge: SHORT) + ): UserPagination + @cacheControl(scope: PRIVATE, maxAge: SHORT) + @checkUserAccess checkImpersonation: UserImpersonation @settings(settingsType: "workspace") @@ -74,12 +71,17 @@ type Query { getSessionWatcher: Boolean @cacheControl(scope: PRIVATE) - getUsersByEmail(email: String!): [User] @cacheControl(scope: PRIVATE) + getUsersByEmail(email: String!): [User] + @cacheControl(scope: PRIVATE) + @checkUserAccess - getActiveUserByEmail(email: String!): User @cacheControl(scope: PRIVATE) + getActiveUserByEmail(email: String!): User + @cacheControl(scope: PRIVATE) + @checkUserAccess getOrganizationsByEmail(email: String!): [Organization] @cacheControl(scope: PRIVATE) + @checkUserAccess } type Mutation { @@ -90,12 +92,12 @@ type Mutation { name: String! slug: String features: [FeatureInput] - ): MutationResponse @cacheControl(scope: PRIVATE) @withSender @auditAccess + ): MutationResponse @cacheControl(scope: PRIVATE) @withSender @checkUserAccess deleteRole(id: ID!): MutationResponse @cacheControl(scope: PRIVATE) @withSender - @auditAccess + @checkUserAccess saveUser( id: ID @@ -159,7 +161,6 @@ type Mutation { @withSession @cacheControl(scope: PRIVATE) @withSender - @auditAccess } type UserImpersonation { diff --git a/node/clients/GraphQLServer.ts b/node/clients/GraphQLServer.ts deleted file mode 100644 index 8a955ed..0000000 --- a/node/clients/GraphQLServer.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { InstanceOptions, IOContext } from '@vtex/api' -import { AppClient, GraphQLClient } from '@vtex/api' - -export class GraphQLServer extends AppClient { - protected graphql: GraphQLClient - - constructor(ctx: IOContext, options?: InstanceOptions) { - super('vtex.graphql-server@1.x', ctx, options) - this.graphql = new GraphQLClient(this.http) - } - - public query = async (query: string, variables: any, extensions: any) => { - return this.graphql.query( - { query, variables, extensions }, - { - params: { - locale: this.context.locale, - }, - url: '/graphql', - } - ) - } -} diff --git a/node/clients/Organizations.ts b/node/clients/Organizations.ts new file mode 100644 index 0000000..0d90d41 --- /dev/null +++ b/node/clients/Organizations.ts @@ -0,0 +1,88 @@ +import type { InstanceOptions, IOContext } from '@vtex/api' +import { AppClient, GraphQLClient } from '@vtex/api' + +import { QUERIES } from '../resolvers/Routes/utils' +import { getTokenToHeader } from './index' + +const getPersistedQuery = () => { + return { + persistedQuery: { + provider: 'vtex.b2b-organizations-graphql@0.x', + sender: 'vtex.storefront-permissions@1.x', + }, + } +} + +export class OrganizationsGraphQLClient extends AppClient { + protected graphql: GraphQLClient + + constructor(ctx: IOContext, options?: InstanceOptions) { + super('vtex.graphql-server@1.x', ctx, options) + this.graphql = new GraphQLClient(this.http) + } + + public getOrganizationById = async (orgId: string): Promise => { + return this.query({ + extensions: getPersistedQuery(), + query: QUERIES.getOrganizationById, + variables: { + id: orgId, + }, + }) + } + + public getB2BSettings = async (): Promise => { + return this.query({ + extensions: getPersistedQuery(), + query: QUERIES.getB2BSettings, + variables: {}, + }) + } + + public getCostCenterById = async (costId: string): Promise => { + return this.query({ + extensions: getPersistedQuery(), + query: QUERIES.getCostCenterById, + variables: { + id: costId, + }, + }) + } + + public getMarketingTags = async (costId: string): Promise => { + return this.query({ + extensions: getPersistedQuery(), + query: QUERIES.getMarketingTags, + variables: { + costId, + }, + }) + } + + public getOrganizationsByEmail = async (email: string): Promise => { + return this.query({ + extensions: getPersistedQuery(), + query: QUERIES.getOrganizationsByEmail, + variables: { email }, + }) + } + + private query = async (param: { + query: string + variables: any + extensions: any + }) => { + const { query, variables, extensions } = param + + return this.graphql.query( + { query, variables, extensions }, + { + params: { + headers: getTokenToHeader(this.context), + locale: this.context.locale, + }, + url: '/graphql', + } + ) + } +} diff --git a/node/clients/index.ts b/node/clients/index.ts index 907b4e9..421c7a3 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -1,14 +1,29 @@ +import type { IOContext } from '@vtex/api' import { IOClients } from '@vtex/api' import { LMClient } from '../utils/LicenseManager' import { ProfileSystemClient } from '../utils/ProfileSystem' import { Checkout } from './checkout' -import { GraphQLServer } from './GraphQLServer' +import FullSessions from './FullSessions' import IdentityClient from './IdentityClient' +import { OrganizationsGraphQLClient } from './Organizations' +import { SalesChannel } from './salesChannel' import { Schema } from './schema' import VtexId from './vtexId' -import { SalesChannel } from './salesChannel' -import FullSessions from './FullSessions' + +export const getTokenToHeader = (ctx: IOContext) => { + const token = + ctx.storeUserAuthToken ?? ctx.adminUserAuthToken ?? ctx.authToken + + const { sessionToken } = ctx + + return { + 'x-vtex-credential': ctx.authToken, + VtexIdclientAutCookie: token, + cookie: `VtexIdclientAutCookie=${token}`, + 'x-vtex-session': sessionToken ?? '', + } +} // Extend the default IOClients implementation with our own custom clients. export class Clients extends IOClients { @@ -24,8 +39,8 @@ export class Clients extends IOClients { return this.getOrSet('checkout', Checkout) } - public get graphqlServer() { - return this.getOrSet('graphqlServer', GraphQLServer) + public get organizations() { + return this.getOrSet('organizations', OrganizationsGraphQLClient) } public get schema() { diff --git a/node/clients/schema.ts b/node/clients/schema.ts index 3e029ee..1459e54 100644 --- a/node/clients/schema.ts +++ b/node/clients/schema.ts @@ -1,6 +1,8 @@ import type { InstanceOptions, IOContext } from '@vtex/api' import { JanusClient } from '@vtex/api' +import { getTokenToHeader } from './index' + const getRouteSchema = (dataEntity: string) => `/api/dataentities/${dataEntity}/schemas` @@ -10,9 +12,7 @@ export class Schema extends JanusClient { ...options, headers: { ...options?.headers, - ...(ctx.storeUserAuthToken - ? { VtexIdclientAutCookie: ctx.storeUserAuthToken } - : { VtexIdclientAutCookie: ctx.authToken }), + ...getTokenToHeader(ctx), 'x-vtex-user-agent': ctx.userAgent, }, }) diff --git a/node/directives/checkUserAccess.ts b/node/directives/checkUserAccess.ts index 6e74e6c..b9d49d0 100644 --- a/node/directives/checkUserAccess.ts +++ b/node/directives/checkUserAccess.ts @@ -3,6 +3,61 @@ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' +export async function checkUserOrAdminTokenAccess( + ctx: Context, + operation?: string +) { + const { + vtex: { adminUserAuthToken, storeUserAuthToken, logger }, + clients: { identity, vtexId }, + } = ctx + + if (!adminUserAuthToken && !storeUserAuthToken) { + logger.warn({ + message: `CheckUserAccess: No admin or store token was provided`, + operation, + }) + throw new AuthenticationError('No admin or store token was provided') + } + + if (adminUserAuthToken) { + try { + await identity.validateToken({ token: adminUserAuthToken }) + } catch (err) { + logger.warn({ + error: err, + message: `CheckUserAccess: Invalid admin token`, + operation, + }) + throw new ForbiddenError('Unauthorized Access') + } + } else if (storeUserAuthToken) { + let authUser = null + + try { + authUser = await vtexId.getAuthenticatedUser(storeUserAuthToken) + if (!authUser?.user) { + logger.warn({ + message: `CheckUserAccess: No valid user found by store user token`, + operation, + }) + authUser = null + } + } catch (err) { + logger.warn({ + error: err, + message: `CheckUserAccess: Invalid store user token`, + operation, + }) + authUser = null + } + + if (!authUser) { + throw new ForbiddenError('Unauthorized Access') + } + } +} + export class CheckUserAccess extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field @@ -13,47 +68,7 @@ export class CheckUserAccess extends SchemaDirectiveVisitor { context: Context, info: any ) => { - const { - vtex: { adminUserAuthToken, storeUserAuthToken, logger }, - clients: { identity, vtexId }, - } = context - - if (!adminUserAuthToken && !storeUserAuthToken) { - throw new AuthenticationError('No admin or store token was provided') - } - - if (adminUserAuthToken) { - try { - await identity.validateToken({ token: adminUserAuthToken }) - } catch (err) { - logger.warn({ - error: err, - message: 'CheckUserAccess: Invalid admin token', - token: adminUserAuthToken, - }) - throw new ForbiddenError('Unauthorized Access') - } - } else if (storeUserAuthToken) { - let authUser = null - - try { - authUser = await vtexId.getAuthenticatedUser(storeUserAuthToken) - if (!authUser?.user) { - authUser = null - } - } catch (err) { - logger.warn({ - error: err, - message: 'CheckUserAccess: Invalid store user token', - token: adminUserAuthToken, - }) - authUser = null - } - - if (!authUser) { - throw new ForbiddenError('Unauthorized Access') - } - } + await checkUserOrAdminTokenAccess(context, field.astNode?.name?.value) return resolve(root, args, context, info) } diff --git a/node/package.json b/node/package.json index cc0dc17..7685a63 100644 --- a/node/package.json +++ b/node/package.json @@ -2,7 +2,7 @@ "name": "vtex.checkout-ui-custom", "version": "1.37.3", "dependencies": { - "@vtex/api": "6.45.19", + "@vtex/api": "6.46.0", "atob": "^2.1.2", "co-body": "^6.0.0", "cookie": "^0.3.1", @@ -21,7 +21,7 @@ "@types/jsonwebtoken": "^8.5.0", "@types/node": "^12.0.0", "@types/ramda": "types/npm-ramda#dist", - "@vtex/api": "6.45.19", + "@vtex/api": "6.46.0", "@vtex/prettier-config": "^0.3.1", "tslint": "^5.12.0", "tslint-config-prettier": "^1.18.0", diff --git a/node/resolvers/Mutations/Users.ts b/node/resolvers/Mutations/Users.ts index 6429b56..9d5420c 100644 --- a/node/resolvers/Mutations/Users.ts +++ b/node/resolvers/Mutations/Users.ts @@ -337,18 +337,13 @@ export const deleteUser = async (_: any, params: any, ctx: Context) => { export const impersonateUser = async (_: any, params: any, ctx: Context) => { const { clients: { session }, - cookies, - request, - vtex: { logger }, + vtex: { logger, sessionToken }, } = ctx const { userId } = params - const sessionCookie = - cookies.get('vtex_session') ?? request.header?.sessiontoken - try { - await session.updateSession('impersonate', userId, [], sessionCookie) + await session.updateSession('impersonate', userId, [], sessionToken) return { status: 'success', message: '' } } catch (error) { diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 57164c8..27a9d58 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -4,7 +4,7 @@ import { json } from 'co-body' import { getRole } from '../Queries/Roles' import { getSessionWatcher } from '../Queries/Settings' import { getActiveUserByEmail, getUserByEmail } from '../Queries/Users' -import { generateClUser, QUERIES } from './utils' +import { generateClUser } from './utils' import { getUser, setActiveUserByOrganization } from '../Mutations/Users' export const Routes = { @@ -90,7 +90,7 @@ export const Routes = { setProfile: async (ctx: Context) => { const { clients: { - graphqlServer, + organizations, masterdata, checkout, profileSystem, @@ -234,23 +234,12 @@ export const Routes = { response['storefront-permissions'].organization.value = user.orgId const getOrganization = async (orgId: any): Promise => { - return graphqlServer - .query( - QUERIES.getOrganizationById, - { id: orgId }, - { - persistedQuery: { - provider: 'vtex.b2b-organizations-graphql@0.x', - sender: 'vtex.storefront-permissions@1.x', - }, - } - ) - .catch((error) => { - logger.error({ - error, - message: 'setProfile.graphqlGetOrganizationById', - }) + return organizations.getOrganizationById(orgId).catch((error) => { + logger.error({ + error, + message: 'setProfile.graphqlGetOrganizationById', }) + }) } const [ @@ -263,39 +252,10 @@ export const Routes = { data: any }> = await Promise.all([ getOrganization(user.orgId), - graphqlServer.query( - QUERIES.getCostCenterById, - { id: user.costId }, - { - persistedQuery: { - provider: 'vtex.b2b-organizations-graphql@0.x', - sender: 'vtex.storefront-permissions@1.x', - }, - } - ), + organizations.getCostCenterById(user.costId), salesChannelClient.getSalesChannel(), - graphqlServer.query( - QUERIES.getMarketingTags, - { - costId: user.costId, - }, - { - persistedQuery: { - provider: 'vtex.b2b-organizations-graphql@0.x', - sender: 'vtex.storefront-permissions@1.x', - }, - } - ), - graphqlServer.query( - QUERIES.getB2BSettings, - {}, - { - persistedQuery: { - provider: 'vtex.b2b-organizations-graphql@0.x', - sender: 'vtex.storefront-permissions@1.x', - }, - } - ), + organizations.getMarketingTags(user.costId), + organizations.getB2BSettings(), ]) let organization = organizationResponse?.data?.getOrganizationById @@ -303,15 +263,8 @@ export const Routes = { // prevent login if org is inactive if (organization.status === 'inactive') { // try to find a valid organization - const organizationsByUserResponse: any = await graphqlServer - .query( - QUERIES.getOrganizationsByEmail, - { email }, - { - provider: 'vtex.b2b-organizations-graphql@0.x', - sender: 'vtex.storefront-permissions@1.x', - } - ) + const organizationsByUserResponse: any = await organizations + .getOrganizationsByEmail(email) .catch((error) => { logger.error({ error, diff --git a/node/yarn.lock b/node/yarn.lock index 95f6298..8dd924f 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -190,10 +190,10 @@ "@types/mime" "^1" "@types/node" "*" -"@vtex/api@6.45.19": - version "6.45.19" - resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.45.19.tgz#97b449850b517d6610e7123aa91caec2a29ae7b1" - integrity sha512-WUsAn1Oi+Z7Ih84DcRf4HQH85Mh+zO84L0ta7zuRyb13DoAEq2qMi0Mv6vZMd9BFWOCSD8AcTHVF/gZskwh9Yw== +"@vtex/api@6.46.0": + version "6.46.0" + resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.46.0.tgz#208d14b96cbc8fd5eb6bd18fbd0c8424886e6154" + integrity sha512-XAvJlD1FG1GynhPXiMcayunahFCL2r3ilO5MHAWKxYvB/ljyxi4+U+rVpweeaQGpxHfhKHdfPe7qNEEh2oa2lw== dependencies: "@types/koa" "^2.11.0" "@types/koa-compose" "^3.2.3" @@ -1428,7 +1428,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -stats-lite@vtex/node-stats-lite#dist: +"stats-lite@github:vtex/node-stats-lite#dist": version "2.2.0" resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/1b0d39cc41ef7aaecfd541191f877887a2044797" dependencies: