diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5d4d3b..b5ee162c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Security +- Scope VTEX ID calls to the requesting account via `{{account}}.vtexcommercestable.com.br`, matching the rewriter credential validation flow and avoiding cross-account token validation through the global VTEX ID host. + ## [7.3.1] - 2026-01-06 ### Added diff --git a/src/clients/external/ID.test.ts b/src/clients/external/ID.test.ts new file mode 100644 index 000000000..27f05f619 --- /dev/null +++ b/src/clients/external/ID.test.ts @@ -0,0 +1,57 @@ +const mockHttpClientInstances: any[] = [] +const mockGet = jest.fn() + +jest.mock('../../HttpClient/HttpClient', () => ({ + HttpClient: jest.fn().mockImplementation((opts) => { + mockHttpClientInstances.push(opts) + return { + get: mockGet, + } + }), +})) + +import { ID } from './ID' + +const context: any = { + account: 'storecomponents', + authToken: 'app-auth-token', +} + +describe('ID client', () => { + beforeEach(() => { + mockHttpClientInstances.length = 0 + mockGet.mockReset() + }) + + it('uses the requesting account commerce stable host', () => { + const client = new ID(context, { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }) + + expect(client).toBeDefined() + expect(mockHttpClientInstances[0]).toMatchObject({ + baseURL: 'http://storecomponents.vtexcommercestable.com.br/api/vtexid/pub/authentication', + headers: { + 'Proxy-Authorization': 'app-auth-token', + 'X-Custom-Header': 'custom-value', + 'X-VTEX-Proxy-To': 'https://storecomponents.vtexcommercestable.com.br', + }, + }) + }) + + it('keeps the temporary token route and metric', async () => { + mockGet.mockResolvedValue({ authenticationToken: 'temporary-token' }) + const client = new ID(context) + + await expect(client.getTemporaryToken()).resolves.toBe('temporary-token') + + expect(mockGet).toHaveBeenCalledWith('/start', { + metric: 'vtexid-temp-token', + tracing: { + requestSpanNameSuffix: 'vtexid-temp-token', + }, + }) + }) +}) diff --git a/src/clients/external/ID.ts b/src/clients/external/ID.ts index 2ffb22f52..07b180102 100644 --- a/src/clients/external/ID.ts +++ b/src/clients/external/ID.ts @@ -9,17 +9,23 @@ const routes = { VALIDATE_CLASSIC: '/classic/validate', } -const VTEXID_ENDPOINTS: Record = { - STABLE: 'https://vtexid.vtex.com.br/api/vtexid/pub/authentication', +const getVtexIdBaseUrl = (account: string) => { + return `http://${account}.vtexcommercestable.com.br/api/vtexid/pub/authentication` } -const endpoint = (env: string) => { - return VTEXID_ENDPOINTS[env] || env +const getProxyTo = (account: string) => { + return `https://${account}.vtexcommercestable.com.br` } export class ID extends ExternalClient { constructor (context: IOContext, opts?: InstanceOptions) { - super(endpoint(VTEXID_ENDPOINTS.STABLE), context, opts) + super(getVtexIdBaseUrl(context.account), context, { + ...opts, + headers: { + ...opts?.headers, + 'X-VTEX-Proxy-To': getProxyTo(context.account), + }, + }) } public getTemporaryToken = (tracingConfig?: RequestTracingConfig) => { diff --git a/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.test.ts b/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.test.ts new file mode 100644 index 000000000..8257acb14 --- /dev/null +++ b/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.test.ts @@ -0,0 +1,75 @@ +import { AuthenticationError } from 'apollo-server-errors' +import axios from 'axios' + +import { Auth } from './Auth' + +jest.mock('axios', () => ({ + request: jest.fn(), +})) + +const request = axios.request as jest.Mock + +function makeContext(overrides: Partial = {}): any { + return { + cookies: { + get: jest.fn((name: string) => name === 'VtexIdclientAutCookie' ? 'valid-vtex-id-token' : undefined), + }, + get: jest.fn(), + vtex: { + account: 'storecomponents', + authToken: 'app-auth-token', + }, + ...overrides, + } +} + +function wrapResolver(args = { productCode: '38', resourceCode: 'cms_settings', scope: 'PRIVATE' }) { + const directive = Object.create(Auth.prototype) + directive.args = args + + const resolve = jest.fn().mockResolvedValue('resolver-result') + const field: any = { resolve } + directive.visitFieldDefinition(field) + + return { field, resolve } +} + +describe('Auth directive', () => { + beforeEach(() => { + request.mockReset() + }) + + it('validates the VTEX ID token against the requesting account commerce stable host', async () => { + request + .mockResolvedValueOnce({ data: { user: 'victor.moura@vtex.com', account: 'storecomponents' } }) + .mockResolvedValueOnce({ data: true }) + + const { field } = wrapResolver() + + await expect(field.resolve({}, {}, makeContext(), {})).resolves.toBe('resolver-result') + + expect(request).toHaveBeenNthCalledWith(1, { + data: { + token: 'valid-vtex-id-token', + }, + headers: { + Accept: 'application/json', + Authorization: 'app-auth-token', + 'Content-Type': 'application/json', + 'X-VTEX-Proxy-To': 'https://storecomponents.vtexcommercestable.com.br', + }, + method: 'post', + url: 'http://storecomponents.vtexcommercestable.com.br/api/vtexid/credential/validate?an=storecomponents', + }) + }) + + it('rejects tokens validated for a different account', async () => { + request.mockResolvedValueOnce({ data: { user: 'victor.moura@vtex.com', account: 'otheraccount' } }) + + const { field } = wrapResolver() + + await expect(field.resolve({}, {}, makeContext(), {})).rejects.toThrow(AuthenticationError) + + expect(request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.ts b/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.ts index 6dd8dd862..3d8837d41 100644 --- a/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.ts +++ b/src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.ts @@ -16,16 +16,21 @@ interface VtexIdParsedToken { account: string } -async function parseIdToken(authToken: string, vtexIdToken: string): Promise { - const url = `vtexid.vtex.com.br/api/vtexid/pub/authenticated/user?authToken=${vtexIdToken}` +async function parseIdToken(authToken: string, vtexIdToken: string, account: string): Promise { + const origin = `${account}.vtexcommercestable.com.br` + const url = `http://${origin}/api/vtexid/credential/validate?an=${account}` const req = await axios.request({ + data: { + token: vtexIdToken, + }, headers: { 'Accept': 'application/json', - 'Proxy-Authorization': authToken, - 'X-VTEX-Proxy-To': `https://${url}`, + 'Authorization': authToken, + 'Content-Type': 'application/json', + 'X-VTEX-Proxy-To': `https://${origin}`, }, - method: 'get', - url: `http://${url}`, + method: 'post', + url, }) if (!req.data) { return undefined @@ -51,7 +56,7 @@ async function auth (ctx: ServiceContext, authArgs: AuthDirectiveArgs): Promise< throw new AuthenticationError('VtexIdclientAutCookie not found.') } - const parsedToken = await parseIdToken(ctx.vtex.authToken, vtexIdToken) + const parsedToken = await parseIdToken(ctx.vtex.authToken, vtexIdToken, ctx.vtex.account) if (!parsedToken || parsedToken.account !== ctx.vtex.account) { throw new AuthenticationError('Could not find user specified by VtexIdclientAutCookie.') } @@ -69,7 +74,7 @@ async function auth (ctx: ServiceContext, authArgs: AuthDirectiveArgs): Promise< } function parseArgs (authArgs: AuthDirectiveArgs): AuthDirectiveArgs { - if (authArgs.scope == 'PUBLIC') { + if (authArgs.scope === 'PUBLIC') { return authArgs } @@ -84,7 +89,7 @@ export class Auth extends SchemaDirectiveVisitor { const {resolve = defaultFieldResolver} = field field.resolve = async (root, args, ctx, info) => { const authArgs = parseArgs(this.args as AuthDirectiveArgs) - if (!authArgs.scope || authArgs.scope == 'PRIVATE') { + if (!authArgs.scope || authArgs.scope === 'PRIVATE') { await auth(ctx, authArgs) } return resolve(root, args, ctx, info)