Implementing ActiveQL
Principal

Principal

To control access to the entity queries and mutations ActiveQL includes a default EntityPermission module that uses the concept of a principal object (with the properties "roles") that is expected in the context (under the name "principal") of any query or mutation.

A principal bears the information of API client / user

  • possible roles for the current
  • possible restrictions to the data handled by the queries and mutations
  • possible API limits

Default vs. custom authentication / principal

Whether and how an application determines a principal object is usually specific to the actual implementation. You would probably integrate a Authentication Provider or SSO, or implement your own logic. In the ActiveQL starter application you will find a JWT based implementation that you can use right away or adapt to your applications requirement.

Example - SimpleLoginDomainConfiguration

To show how you would add your own implementation to obtain and provide a principal to your ActiveQL application we will create a bit similar solution to the default JWT based implementation but use a quite simple approach with a User entity as prinicipal that will hold all information to grant or prohibit any API client's access to certain queries and mutations.

You will probably not use such an implementation in a production environment. But it showcases the basic principles of authentication and authorization implementations.

User Entity and Seed Data

We put everything in a file ./simple-login.ts from where we export the DomainConfiguration which then should be included by the DomainGraphBuilder in our runtime-configuration.ts.

We start by adding a User entity that holds the authentication information.

export const simpleLoginDomainConfiguration:DomainConfiguration = {
  entity: {
    User: {
      attributes: {
        username: 'Key',
        password: {
          type: 'String!',
          resolve: () => '***'
        },
        roles: '[String!]'
      },
      permissions: {
        admin: true
      },
      seeds: {
        admin: {
          username: 'admin',
          password: hash( 'admin'),
          roles: ['admin']
        },
        manager: {
          username: 'manager',
          password: hash( 'manager'),
          roles: ['manager', 'user']
        },
        user: {
          username: 'user',
          password: hash( 'user'),
          roles: ['user']
        },
        assistant: {
          username: 'assistant',
          password: hash( 'assistant'),
          roles: ['assistant']
        }
      }
    }
  }
}

The configuration should not be surprise you. We seed four users with their respective roles. And we add a permission configuration to it, so only admin users can manage user items.

Obviously we don't want to store the passwords in plaintext, therefore we seed the hashed values, using a hash method that we included in the file. This method uses the bcryptjs (opens in a new tab) library to create a secure hash of a plaintext password.

import bcrypt from 'bcryptjs';
 
const hash = (password:string) => bcrypt.hashSync( password, bcrypt.genSaltSync(10) );

You may have noticed the resolve method of the password attribute. Even if the password is stored as a hash we do not want to expose it over the API therefore we always return "***" instead.

Login Implementation

A client should be able to call a login method on our API with a "username" and "password" and if the credentials are valid gets a token to send along the following GraphQL requests.

For this we add custom mutation login.

const users:{[token:string]:{ user:any, date:number }} = {};
 
const login = async (runtime:Runtime, username:string, password:string) => {
  const user = await findUser( runtime, username );  
  if( ! user || ! await bcrypt.compare( password, user.password ) ) return undefined;
  const token = generateToken();
  setUser( user, token );
  return token;
}
 
const findUser = async ( runtime:Runtime, username:string ) => {
  const entity = runtime.entity('User')
  return entity.findOneByAttribute( { username } );  
}
 
const generateToken = () => hash( _.toString(_.random(9999999) ) );
 
const invalidToken = (token:string) => _.unset( users, [token] );
 
const setUser = (user:any, token:string) => {
  const roles = user.roles;
  _.set( user, 'roles', () => _.includes( roles, 'admin' ) ? true : roles );
  _.set( users, [token], { user, date: Date.now() } );
}
 
export const simpleLoginDomainConfiguration:DomainConfiguration = {
  entity: {
    User: {
      // left out
    },
  }
  mutation: {
    login: {
      type: 'String',
      args: {
        username: 'String!',
        password: 'String!'
      },
      resolve: (__:any, {username, password}) => login( runtime, username, password )
    }
  }
}

The mutation defines the input (username, password) and return type, the token-string. It delegates the resolve to the login function that:

  • loads the according user item for the username from the datastore
  • compares the credentials - password-hash from user item with provided password
  • if successful - creates a token (just a random number, hashed again)
  • stores the user item under this token - along with the current DateTime and
  • and returns the token to the API client.

We manipulate the user item in a way that we do not expose the roles from the datastore directly but add a roles function instead in which we simply return true if any of the user's roles include the "admin" role. With this such a super user is allowed to access any entity's queries and mutation, even if the "admin" role is not included specifically to an entity definition.

Test the Login mutation

Before we can login we need to seed the datastore.

mutation{ seed( truncate: true ) }

After that we can test the login mutation.

Request:

mutation Login {
  login( username: "user", password: "user" )
}

Response:

{
  "data": {
    "login": "$2a$10$yeMJeOLaWQNy4eNjKV250.6W.5e61ndvr24r76XwRU6lR87QOrgym"
  }
}

We now have a login token we can store and use later to authorize following client requests. Let's see what happens when we try to call this mutation with an incorrect username/password.

Request:

mutation Login {
  // wrong username
  login( username: "Xuser", password: "user" )
}

Response:

{
  "data": {
    "login": null
  }
}

Request:

mutation Login {
  // wrong password
  login( username: "user", password: "userX" )
}

Response:

{
  "data": {
    "login": null
  }
}

As expected we do not get a token.

Authorization Request

The default EntityPermissions implementation expects a principal to have either roles attribute or function that return an array of roles or a boolean value. We need to add that functionality to every request. Therefore we check if the client provided us with a valid token. If so we put the user into the context from which it is used by the EntityPermissions or any other method.

For this we add a contextFn hook that is called before any request is handed to Apollo GraphQL. It gives us access to the context object.

const MAX_VALID = 12 * 60 * 60 * 1000; // 12 hours
 
const invalidToken = (token:string) => _.unset( users, [token] );
 
const addPrincipalToApolloContext = async (request:IncomingMessage, apolloContext:any) => {
  const token = request.headers.authorization;  
  const principal = token ? validUser( token ) : undefined;
  _.set( apolloContext, 'principal', principal );
}
 
const validUser = ( token:string) => {
  const entry = users[token];
  if( entry && (Date.now() - entry.date) < MAX_VALID ) return entry.user;
  invalidToken( token );
}
 
export const simpleLoginDomainConfiguration:DomainConfiguration = {
  entity: {
    User: {
      // left out
    }
  },
  mutation: {
    // left out
  },
  contextFn: addPrincipalToApolloContext
}

The implementation of the addPrincipalToApolloContext method

  • checks for a token in the http header (Authorization)
  • tries to find a user object in the users map for this token, were it was stored by the login method
  • checks if the timestamp is not older than MAX_VALID
  • if successful it sets the user as principal in the context object.

Test Authorized Requests

Let's assume in our DomainGraph we have a Car entity with some seed data as follows:

enum: 
  Brand: 
    - Mercedes
    - BMW
    - Audi
    - Porsche 
 
entity:
  Car:  
    attributes:
      color: String
      brand: String
      power: Int
      
    seeds: 
      20:
        color: 
          faker: color.human
        brand:
          sample: Brand
        power:
          random: 
            min: 70
            max: 200
 
    permissions: 
      admin: true
      manager: 
        create: true
        read: true
        update: true
      user: 
        read: true

We have also added some permissions. User with admin role have all permissions. User with the role manager can create, read and update car items, User with the role user can only read, everyone else has no permissions.

So let's see what happens if we try to query the car items. Since we already seeded the datastore for our User items, Car items should exist.

query{ 
  cars {
    id
    brand
    color
    power
  }
}
{
  "data": {
    "cars": []
  }
}

Since we did not provide any token, no principal was set and we do not have any permission. As you see this is not considered an error. The result is simply empty.

Luckily we have the token from the Login we did earlier. We set it in the Authorization header (excerpt):

accept: '*/*'
accept-encoding: 'gzip, deflate, br'
Authorization: $2a$14$HSUBNNIrZdiVXynnXZ8TYOjmqhnl7FYvdoivdJe6pxGJ8wnIacvn2

Please note we do not use the "Bearer {token} " syntax here, but set the token as the sole value of the Authorization header. This is just for simplification. Otherwise we need to extract the token from the header string after the Bearer keyword in the addPrincipalToApolloContext method.

The same request will now get us the seeded car items.

query{ 
  cars {
    id
    brand
    color
    power
  }
}
{
  "data": {
    "cars": [
      {
        "id": "yulne9XXwfWM2VH4",
        "brand": "Audi",
        "color": "white",
        "power": 104
      },
      {
        "id": "uYByVUHSDZ34grQq",
        "brand": "Porsche",
        "color": "lavender",
        "power": 200
      }
    ]
  }
}

You can check yourself what happens when you try to create, update or delete a car item with a login with an insufficient role.