Skip to content

Commit

Permalink
fix: add token validation directive (#143)
Browse files Browse the repository at this point in the history
* fix: add token validation directive

* fix: add additional check to admin token

* chore: fix cognitive load from the code

* fix: rename access directive

* fix: reduce cognitive load of the functions

* fix: log errors that happened on the validation

* fix: catch and log exceptions on token validations
  • Loading branch information
Matheus-Aguilar authored Jul 1, 2024
1 parent 3fef2e0 commit 48826f0
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 32 deletions.
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]

### Added
- Add token validation directive

## [1.40.7] - 2024-06-11

### Fixed
Expand Down
1 change: 1 addition & 0 deletions graphql/directives.graphql
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
directive @checkUserAccess on FIELD | FIELD_DEFINITION
directive @validateStoreUserAccess on FIELD | FIELD_DEFINITION
directive @checkAdminAccess on FIELD | FIELD_DEFINITION
directive @withSession on FIELD_DEFINITION
directive @withSender on FIELD_DEFINITION
Expand Down
8 changes: 2 additions & 6 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type Query {
listAllUsers: [User]
@cacheControl(scope: PRIVATE, maxAge: SHORT)
@withSender
@checkUserAccess
@validateStoreUserAccess

listUsers(organizationId: ID, costCenterId: ID, roleId: ID): [User]
@cacheControl(scope: PRIVATE, maxAge: SHORT)
Expand Down Expand Up @@ -71,11 +71,7 @@ type Query {

getSessionWatcher: Boolean @cacheControl(scope: PRIVATE)

getUsersByEmail(
email: String!
orgId: ID
costId: ID
): [User]
getUsersByEmail(email: String!, orgId: ID, costId: ID): [User]
@cacheControl(scope: PRIVATE)
@checkUserAccess

Expand Down
22 changes: 20 additions & 2 deletions node/directives/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const validateAdminToken = async (
hasCurrentValidAdminToken: boolean
}> => {
const {
clients: { identity },
clients: { identity, lm },
vtex: { logger },
} = context

// check if has admin token and if it is valid
Expand All @@ -29,10 +30,17 @@ export const validateAdminToken = async (
hasCurrentValidAdminToken = true

if (authUser?.audience === 'admin') {
hasValidAdminToken = true
hasValidAdminToken = await lm.getUserAdminPermissions(
authUser.account,
authUser.id
)
}
} catch (err) {
// noop so we leave hasValidAdminToken as false
logger.warn({
message: 'Error validating admin token',
err,
})
}
}

Expand All @@ -47,6 +55,7 @@ export const validateApiToken = async (
}> => {
const {
clients: { identity },
vtex: { logger },
} = context

// check if has api token and if it is valid
Expand All @@ -71,6 +80,10 @@ export const validateApiToken = async (
}
} catch (err) {
// noop so we leave hasValidApiToken as false
logger.warn({
message: 'Error validating API token',
err,
})
}
}

Expand All @@ -87,6 +100,7 @@ export const validateStoreToken = async (
}> => {
const {
clients: { vtexId },
vtex: { logger },
} = context

// check if has store token and if it is valid
Expand Down Expand Up @@ -115,6 +129,10 @@ export const validateStoreToken = async (
}
} catch (err) {
// noop so we leave hasValidStoreToken as false
logger.warn({
message: 'Error validating store token:',
err,
})
}
}

Expand Down
2 changes: 2 additions & 0 deletions node/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { WithSender } from './withSender'
import { WithUserPermissions } from './withUserPermissions'
import { CheckAdminAccess } from './checkAdminAccess'
import { CheckUserAccess } from './checkUserAccess'
import { ValidateStoreUserAccess } from './validateStoreUserAccess'
import { AuditAccess } from './auditAccess'

export const schemaDirectives = {
checkAdminAccess: CheckAdminAccess as any,
checkUserAccess: CheckUserAccess as any,
validateStoreUserAccess: ValidateStoreUserAccess as any,
withSession: WithSession,
withSender: WithSender,
withUserPermissions: WithUserPermissions,
Expand Down
132 changes: 132 additions & 0 deletions node/directives/validateStoreUserAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { AuthenticationError, ForbiddenError } from '@vtex/api'
import type { GraphQLField } from 'graphql'
import { defaultFieldResolver } from 'graphql'
import { SchemaDirectiveVisitor } from 'graphql-tools'

import type { AuthAuditMetric } from '../metrics/auth'
import sendAuthMetric, { AuthMetric } from '../metrics/auth'
import {
validateAdminToken,
validateApiToken,
validateStoreToken,
} from './helper'

export class ValidateStoreUserAccess extends SchemaDirectiveVisitor {
public visitFieldDefinition(field: GraphQLField<any, any>) {
const { resolve = defaultFieldResolver } = field

field.resolve = async (
root: any,
args: any,
context: Context,
info: any
) => {
const {
vtex: { adminUserAuthToken, storeUserAuthToken, logger },
} = context

// get metrics data
const operation = field?.astNode?.name?.value ?? context?.request?.url
const userAgent = context?.request?.headers['user-agent'] as string
const caller = context?.request?.headers['x-vtex-caller'] as string
const forwardedHost = context?.request?.headers[
'x-forwarded-host'
] as string

// set metric fields with initial data
let metricFields: AuthAuditMetric = {
operation,
forwardedHost,
caller,
userAgent,
}

const { hasAdminToken, hasValidAdminToken } = await validateAdminToken(
context,
adminUserAuthToken as string
)

// add admin token metrics
metricFields = { ...metricFields, hasAdminToken, hasValidAdminToken }

// allow access if has valid admin token
if (hasValidAdminToken) {
sendAuthMetric(
logger,
new AuthMetric(
context?.vtex?.account,
metricFields,
'ValidateStoreUserAccessAudit'
)
)

return resolve(root, args, context, info)
}

const { hasApiToken, hasValidApiToken } = await validateApiToken(context)

// add API token metrics
metricFields = {
...metricFields,
hasApiToken,
hasValidApiToken,
}

// allow access if has valid API token
if (hasValidApiToken) {
sendAuthMetric(
logger,
new AuthMetric(
context?.vtex?.account,
metricFields,
'ValidateStoreUserAccessAudit'
)
)

return resolve(root, args, context, info)
}

const { hasStoreToken, hasValidStoreToken } = await validateStoreToken(
context,
storeUserAuthToken as string
)

// add store token metrics
metricFields = {
...metricFields,
hasStoreToken,
hasValidStoreToken,
}

// allow access if has valid store token
if (hasValidStoreToken) {
sendAuthMetric(
logger,
new AuthMetric(
context?.vtex?.account,
metricFields,
'ValidateStoreUserAccessAudit'
)
)

return resolve(root, args, context, info)
}

// deny access if no tokens were provided
if (!hasAdminToken && !hasApiToken && !hasStoreToken) {
logger.warn({
message: 'ValidateStoreUserAccess: No token provided',
...metricFields,
})
throw new AuthenticationError('No token was provided')
}

// deny access if no valid tokens were provided
logger.warn({
message: `ValidateStoreUserAccess: Invalid token`,
...metricFields,
})
throw new ForbiddenError('Unauthorized Access')
}
}
}
6 changes: 3 additions & 3 deletions node/metrics/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export interface AuthAuditMetric {
userAgent: string
role?: string
permissions?: string[]
hasAdminToken: boolean
hasAdminToken?: boolean
hasValidAdminToken?: boolean
hasStoreToken: boolean
hasStoreToken?: boolean
hasValidStoreToken?: boolean
hasApiToken: boolean
hasApiToken?: boolean
hasValidApiToken?: boolean
}

Expand Down
37 changes: 16 additions & 21 deletions node/resolvers/Queries/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,24 @@ export const isUserPartOfBuyerOrg = async (email: string, ctx: Context) => {
clients: { masterdata },
} = ctx

try {
const where = `email=${email}`
const resp = await masterdata.searchDocumentsWithPaginationInfo({
dataEntity: config.name,
fields: ['id'], // we don't need to fetch all fields, only if there is an entry or not
pagination: {
page: 1,
pageSize: 1, // we only need to know if there is at least one user entry
},
schema: config.version,
...(where ? { where } : {}),
})
const where = `email=${email}`
const resp = await masterdata.searchDocumentsWithPaginationInfo({
dataEntity: config.name,
fields: ['id'], // we don't need to fetch all fields, only if there is an entry or not
pagination: {
page: 1,
pageSize: 1, // we only need to know if there is at least one user entry
},
schema: config.version,
...(where ? { where } : {}),
})

const { data } = resp as unknown as {
data: any
}
const { data } = resp as unknown as {
data: any
}

if (data.length > 0) {
return true
}
} catch (error) {
// if it fails at somepoint, we treat it like no user was found
// on any buyer org, so we just let the function return false
if (data.length > 0) {
return true
}

return false
Expand Down
10 changes: 10 additions & 0 deletions node/utils/LicenseManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export class LMClient extends ExternalClient {
return user ? this.delete(this.routes.deleteUser(userId, '957'), {}) : {}
}

public getUserAdminPermissions = async (account: string, userId: string) => {
return this.get(this.routes.getUserAdminPermissions(account, userId)).then(
(res: any) => {
return res
}
)
}

protected get = <T>(url: string) => {
return this.http.get<T>(url).catch(statusToError)
}
Expand Down Expand Up @@ -103,6 +111,8 @@ export class LMClient extends ExternalClient {
userByEmail: (email: string) =>
`api/license-manager/pvt/users/${encodeURIComponent(email)}`,
userById: (id: string) => `api/license-manager/pvt/users/${id}`,
getUserAdminPermissions: (account: string, userId: string) =>
`/api/license-manager/pvt/accounts/${account}/logins/${userId}/granted`,
}
}
}

0 comments on commit 48826f0

Please sign in to comment.