loopbackio / loopback-next

LoopBack makes it easy to build modern API applications that require complex integrations.
https://loopback.io
Other
4.97k stars 1.07k forks source link

Additional properties in JWT token not accessible #9914

Open apfz opened 1 year ago

apfz commented 1 year ago

Describe the bug

I am trying to access my User model's "roles" properties. I am using the @loopback/authentication-jwt package for JWT authentication.

I have tried to bind a custom JWTService with application.ts as follows:

this.bind(TokenServiceBindings.TOKEN_SERVICE).toClass(JWTService);

The custom JWT Service is as follows:

import {TokenService} from '@loopback/authentication';
import {inject} from '@loopback/context';
import {HttpErrors} from '@loopback/rest';
import {securityId, UserProfile} from '@loopback/security';
import {promisify} from 'util';
import {TokenServiceBindings} from '@loopback/authentication-jwt';

const jwt = require('jsonwebtoken');
const signAsync = promisify(jwt.sign);
const verifyAsync = promisify(jwt.verify);

export class JWTService implements TokenService {
  constructor(
    @inject(TokenServiceBindings.TOKEN_SECRET)
    private jwtSecret: string,
    @inject(TokenServiceBindings.TOKEN_EXPIRES_IN)
    private jwtExpiresIn: string,
  ) {}

  async verifyToken(token: string): Promise<UserProfile> {
    if (!token) {
      throw new HttpErrors.Unauthorized(
        `Error verifying token: 'token' is null`,
      );
    }

    let userProfile: UserProfile;

    try {
      // decode user profile from token
      const decodedToken = await verifyAsync(token, this.jwtSecret);
      //Don't copy over  token fields 'iat' and 'exp', nor 'email' to the user profile
      userProfile = Object.assign(
        {[securityId]: '', name: ''},
        {
          [securityId]: decodedToken.id,
          name: decodedToken.name,
          id: decodedToken.id,
          roles: decodedToken.roles,
        },
      );
    } catch (error) {
      throw new HttpErrors.Unauthorized(
        `Error verifying token : ${error.message}`,
      );
    }
    return userProfile;
  }

  async generateToken(userProfile: UserProfile): Promise<string> {
    if (!userProfile) {
      throw new HttpErrors.Unauthorized(
        'Error generating token: userProfile is null',
      );
    }
    const userInfoForToken = {
      id: userProfile[securityId],
      name: userProfile.name,
      roles: userProfile.roles,
    };
    // Generate a JSON Web Token
    let token: string;
    try {
      token = await signAsync(userInfoForToken, this.jwtSecret, {
        expiresIn: Number(this.jwtExpiresIn),
      });
    } catch (error) {
      throw new HttpErrors.Unauthorized(`Error encoding token : ${error}`);
    }

    return token;
  }
}

Now I decorated an endpoint in the following way:

@post('/faqs', {
    security: OPERATION_SECURITY_SPEC,
    responses: {
      '200': {
        description: 'Faq model instance',
        content: {'application/json': {schema: getModelSchemaRef(Faq)}},
      },
    },
  })
  @authenticate('jwt')
  @authorize({
    allowedRoles: ['faqs'],
    voters: [basicAuthorization],
  })

But the JWT service never seems to get triggered.

The basicAuthorization of my decorator is as follows:

import {
  AuthorizationContext,
  AuthorizationDecision,
  AuthorizationMetadata,
} from '@loopback/authorization';
import {securityId, UserProfile} from '@loopback/security';
import _ from 'lodash';

// Instance level authorizer
// Can be also registered as an authorizer, depends on users' need.
export async function basicAuthorization(
  authorizationCtx: AuthorizationContext,
  metadata: AuthorizationMetadata,
): Promise<AuthorizationDecision> {
  // No access if authorization details are missing
  let currentUser: UserProfile;

  if (authorizationCtx.principals.length > 0) {
    const user = _.pick(authorizationCtx.principals[0], [
      'id',
      'name',
      'roles',
    ]);
    currentUser = {[securityId]: user.id, name: user.name, roles: user.roles};
  } else {
    return AuthorizationDecision.DENY;
  }

  if (!currentUser.roles) {
    return AuthorizationDecision.DENY;
  }

  // Authorize everything that does not have a allowedRoles property
  if (!metadata.allowedRoles) {
    return AuthorizationDecision.ALLOW;
  }

  let roleIsAllowed = false;
  for (const role of currentUser.roles) {
    if (metadata.allowedRoles!.includes(role)) {
      roleIsAllowed = true;
      break;
    }
  }

  if (!roleIsAllowed) {
    return AuthorizationDecision.DENY;
  }

  // Admin and support accounts bypass id verification
  if (
    currentUser.roles.includes('admin') ||
    currentUser.roles.includes('support')
  ) {
    return AuthorizationDecision.ALLOW;
  }

  /**
   * Allow access only to model owners, using route as source of truth
   *
   * eg. @post('/users/{userId}/orders', ...) returns `userId` as args[0]
   */
  if (currentUser[securityId] === authorizationCtx.invocationContext.args[0]) {
    return AuthorizationDecision.ALLOW;
  }

  return AuthorizationDecision.DENY;
}

However, roles is undefined here.

How can I solve this?

Logs

No response

Additional information

No response

Reproduction

-

johndpope commented 1 year ago

maybe helpful - https://github.com/Service-Soft/lbx-jwt or try digging through these results - https://github.com/search?q=loopback%2Fauthentication-jwt+roles&type=code

apfz commented 1 year ago

@johndpope Thanks. The lbx-jwt repository is really helpful. I have replaced my code with the repository, as I couldn't quite figure out what went wrong with my implementation.

happyjoe1978 commented 8 months ago

Perhaps it was an import package error. application.ts

import {JWTService} from './services/jwt.service';