Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

feat: refactor directives, add auth #8

Merged
merged 5 commits into from
Jul 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@
"@types/keycloak-connect": "4.5.1",
"@types/node": "10.14.10",
"@types/pino": "5.20.0",
"@types/sinon": "^7.0.13",
"ava": "2.1.0",
"coveralls": "3.0.4",
"express": "^4.17.1",
"graphql": "14.4.1",
"nyc": "14.1.1",
"sinon": "^7.3.2",
"ts-node": "8.3.0",
"tslint": "5.18.0",
"typescript": "3.5.2"
Expand All @@ -52,6 +54,9 @@
"nyc": {
"extension": [
".ts"
],
"include": [
"src/**/*.ts"
]
},
"ava": {
Expand Down
2 changes: 1 addition & 1 deletion src/AuthContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class KeycloakAuthContextProvider implements AuthContextProvider {
constructor ({ req }: { req: any }) {
this.request = req
this.accessToken = (req && req.kauth && req.kauth.grant) ? req.kauth.grant.access_token : undefined
this.authenticated = !!((req && req.kauth && req.kauth.grant) ? req.kauth.grant.access_token : undefined)
this.authenticated = this.accessToken && !this.accessToken.isExpired()
}

public isAuthenticated (): boolean {
Expand Down
3 changes: 2 additions & 1 deletion src/KeycloakSecurityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export class KeycloakSecurityService implements SecurityService {
}

public getTypeDefs(): string {
return 'directive @hasRole(role: [String]) on FIELD | FIELD_DEFINITION'
return `directive @hasRole(role: [String]) on FIELD | FIELD_DEFINITION
directive @auth on FIELD | FIELD_DEFINITION`
}

public getSchemaDirectives (): SchemaDirectives {
Expand Down
22 changes: 22 additions & 0 deletions src/schemaDirectives/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defaultFieldResolver, GraphQLSchema } from 'graphql'
import { SchemaDirectiveVisitor } from 'graphql-tools'
import { directiveResolvers } from './directiveResolvers'
import { VisitableSchemaType } from 'graphql-tools/dist/schemaVisitor'

export class AuthDirective extends SchemaDirectiveVisitor {

constructor (config: {
name: string
visitedType: VisitableSchemaType
schema: GraphQLSchema
context: { [key: string]: any }
}) {
// see https://github.com/apollographql/graphql-tools/issues/837
super(config as any)
}

public visitFieldDefinition (field: any) {
const { resolve = defaultFieldResolver } = field
field.resolve = directiveResolvers.auth(resolve)
}
}
25 changes: 25 additions & 0 deletions src/schemaDirectives/directiveResolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const directiveResolvers = {
auth: (next: Function) => (root: any, args: any, context: any, info: any) => {
if (!context.auth || !context.auth.isAuthenticated()) {
throw new Error(`User not Authenticated`)
}
return next(root, args, context, info)
},
hasRole: (roles: Array<string>) => (next: Function) => (root: any, args: any, context: any, info: any) => {
if (!context.auth || !context.auth.isAuthenticated()) {
throw new Error(`User not Authenticated`)
}

let foundRole = null // this will be the role the user was successfully authorized on

foundRole = roles.find((role: string) => {
Copy link
Contributor

@wtrocki wtrocki Jul 6, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this will be vulnerable to code swapping. Using classical for loop may be more secure as Array.find is global.
Request or context aren't so easy to swap.

return context.auth.hasRole(role)
})

if (!foundRole) {
throw new Error(`User is not authorized. Must have one of the following roles: [${roles}]`)
}

return next(root, args, context, info)
}
}
37 changes: 2 additions & 35 deletions src/schemaDirectives/hasRole.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { ForbiddenError } from 'apollo-server-express'
import { defaultFieldResolver, GraphQLSchema } from 'graphql'
import { SchemaDirectiveVisitor } from 'graphql-tools'
import Joi from 'joi'
import pino from 'pino' // also need to figure out where this comes from

import { directiveResolvers } from './directiveResolvers'
import { VisitableSchemaType } from 'graphql-tools/dist/schemaVisitor'

const log = pino()

export class HasRoleDirective extends SchemaDirectiveVisitor {

constructor (config: {
Expand All @@ -25,41 +21,12 @@ export class HasRoleDirective extends SchemaDirectiveVisitor {
const { resolve = defaultFieldResolver } = field
const { error, value } = this.validateArgs()
if (error) {
log.error(`Invalid hasRole directive on field ${field.name}`, error)
throw error
}

const { roles } = value

field.resolve = async function (root: any, args: any, context: any, info: any) {
log.info(`checking user is authorized to access ${field.name} on parent ${info.parentType.name}. Must have one of [${roles}]`)

if (!context.auth || !context.auth.isAuthenticated()) {
const AuthorizationErrorMessage = `Unable to find authentication. Authorization is required for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${roles}]`
log.error({ error: AuthorizationErrorMessage })
throw new ForbiddenError(AuthorizationErrorMessage)
}

const token = context.auth.accessToken

let foundRole = null // this will be the role the user was successfully authorized on

foundRole = roles.find((role: string) => {
return context.auth.hasRole(role)
})

if (!foundRole) {
const AuthorizationErrorMessage = `user is not authorized for field ${field.name} on parent ${info.parentType.name}. Must have one of the following roles: [${roles}]`
log.error({ error: AuthorizationErrorMessage, details: token.content })
throw new ForbiddenError(AuthorizationErrorMessage)
}

log.info(`user successfully authorized with role: ${foundRole}`)

// Return appropriate error if this is false
const result = await resolve.apply(this, [root, args, context, info])
return result
}
field.resolve = directiveResolvers.hasRole(roles)(resolve)
}

public validateArgs () {
Expand Down
2 changes: 2 additions & 0 deletions src/schemaDirectives/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { HasRoleDirective } from './hasRole'
import { AuthDirective } from './auth'

export const schemaDirectives = {
auth: AuthDirective,
hasRole: HasRoleDirective
}
106 changes: 106 additions & 0 deletions test/AuthContextProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import test from 'ava'

import { KeycloakAuthContextProvider } from '../src/AuthContextProvider'

test('AuthContextProvider accessToken is the access_token in req.kauth', (t) => {

const req = {
kauth: {
grant: {
access_token: {
hasRole: (role: string) => {
return true
},
isExpired: () => {
return false
}
}
}
}
}

const provider = new KeycloakAuthContextProvider({ req })
t.deepEqual(provider.accessToken, req.kauth.grant.access_token)
})

test('AuthContextProvider hasRole calls hasRole in the access_token', (t) => {
t.plan(2)
const req = {
kauth: {
grant: {
access_token: {
hasRole: (role: string) => {
t.pass()
return true
},
isExpired: () => {
return false
}
}
}
}
}

const provider = new KeycloakAuthContextProvider({ req })
t.truthy(provider.hasRole(''))
})

test('AuthContextProvider.isAuthenticated is true when token is defined and isExpired returns false', (t) => {
const req = {
kauth: {
grant: {
access_token: {
hasRole: (role: string) => {
return true
},
isExpired: () => {
return false
}
}
}
}
}

const provider = new KeycloakAuthContextProvider({ req })
t.truthy(provider.isAuthenticated())
})

test('AuthContextProvider.isAuthenticated is false when token is defined but isExpired returns true', (t) => {
const req = {
kauth: {
grant: {
access_token: {
hasRole: (role: string) => {
return true
},
isExpired: () => {
return true
}
}
}
}
}

const provider = new KeycloakAuthContextProvider({ req })
t.false(provider.isAuthenticated())
})

test('AuthContextProvider.hasRole is false if token is expired', (t) => {
const req = {
kauth: {
grant: {
access_token: {
hasRole: (role: string) => {
return true
},
isExpired: () => {
return true
}
}
}
}
}

const provider = new KeycloakAuthContextProvider({ req })
t.false(provider.hasRole(''))
})