Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/clients/external/ID.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
})
})
16 changes: 11 additions & 5 deletions src/clients/external/ID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ const routes = {
VALIDATE_CLASSIC: '/classic/validate',
}

const VTEXID_ENDPOINTS: Record<string, string> = {
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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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)
})
})
23 changes: 14 additions & 9 deletions src/service/worker/runtime/graphql/schema/schemaDirectives/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ interface VtexIdParsedToken {
account: string
}

async function parseIdToken(authToken: string, vtexIdToken: string): Promise<VtexIdParsedToken | void> {
const url = `vtexid.vtex.com.br/api/vtexid/pub/authenticated/user?authToken=${vtexIdToken}`
async function parseIdToken(authToken: string, vtexIdToken: string, account: string): Promise<VtexIdParsedToken | void> {
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
Expand All @@ -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.')
}
Expand All @@ -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
}

Expand All @@ -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)
Expand Down