Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add token validation directive #143

Merged
Merged
Show file tree
Hide file tree
Changes from 3 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]

### 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 @validateUserAccess 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
@validateUserAccess

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
9 changes: 7 additions & 2 deletions node/directives/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const validateAdminToken = async (
hasCurrentValidAdminToken: boolean
}> => {
const {
clients: { identity },
clients: { identity, lm },
} = context

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

if (authUser?.audience === 'admin') {
hasValidAdminToken = true
const hasAdminPermissions = await lm.getUserAdminPermissions(
enzomerca marked this conversation as resolved.
Show resolved Hide resolved
authUser.account,
authUser.id
)

hasValidAdminToken = hasAdminPermissions
enzomerca marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (err) {
// noop so we leave hasValidAdminToken as false
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 { ValidateUserAccess } from './validateUserAccess'
import { AuditAccess } from './auditAccess'

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

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

export class ValidateUserAccess extends SchemaDirectiveVisitor {
enzomerca marked this conversation as resolved.
Show resolved Hide resolved
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

let hasTokens = true
let hasValidTokens = true

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

// add Admin token metrics
let tokenMetrics: AuthAuditTokenMetrics = {
hasAdminToken,
hasValidAdminToken,
}

if (!hasAdminToken || !hasValidAdminToken) {
const { hasApiToken, hasValidApiToken } = await validateApiToken(
context
)

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

if (!hasApiToken || !hasValidApiToken) {
const { hasStoreToken, hasValidStoreToken } =
await validateStoreToken(context, storeUserAuthToken as string)

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

if (!hasStoreToken || !hasValidStoreToken) {
hasTokens = hasAdminToken || hasApiToken || hasStoreToken
hasValidTokens =
hasValidAdminToken || hasValidApiToken || hasValidStoreToken
}
}
}
enzomerca marked this conversation as resolved.
Show resolved Hide resolved

// now we emit a metric with all the collected data before we proceed
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

const auditMetric = new AuthMetric(
context?.vtex?.account,
{
operation,
forwardedHost,
caller,
userAgent,
...tokenMetrics,
},
'ValidateUserAccessAudit'
)

sendAuthMetric(logger, auditMetric)

if (!hasTokens) {
logger.warn({
message: 'ValidateUserAccess: No token provided',
userAgent,
caller,
forwardedHost,
operation,
...tokenMetrics,
})
throw new AuthenticationError('No token was provided')
}

if (!hasValidTokens) {
logger.warn({
message: `ValidateUserAccess: Invalid token`,
userAgent,
caller,
forwardedHost,
operation,
...tokenMetrics,
})
throw new ForbiddenError('Unauthorized Access')
}

return resolve(root, args, context, info)
}
}
}
17 changes: 10 additions & 7 deletions node/metrics/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ import type { Logger } from '@vtex/api/lib/service/logger/logger'
import type { Metric } from '../clients/metrics'
import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics'

export interface AuthAuditMetric {
export interface AuthAuditTokenMetrics {
enzomerca marked this conversation as resolved.
Show resolved Hide resolved
hasAdminToken: boolean
hasValidAdminToken?: boolean
hasStoreToken?: boolean
hasValidStoreToken?: boolean
hasApiToken?: boolean
hasValidApiToken?: boolean
}

export interface AuthAuditMetric extends AuthAuditTokenMetrics {
operation: string
forwardedHost: string
caller: string
userAgent: string
role?: string
permissions?: string[]
hasAdminToken: boolean
hasValidAdminToken?: boolean
hasStoreToken: boolean
hasValidStoreToken?: boolean
hasApiToken: boolean
hasValidApiToken?: boolean
}

export class AuthMetric implements Metric {
Expand Down
12 changes: 12 additions & 0 deletions node/utils/LicenseManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ 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
})
.catch(() => {
return false
})
}

protected get = <T>(url: string) => {
return this.http.get<T>(url).catch(statusToError)
}
Expand Down Expand Up @@ -103,6 +113,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`,
}
}
}
Loading