loopbackio / loopbackio/loopback-next

Additional properties in JWT token not accessible

Open
#9,914 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
TypeScript
Stars
5.1k
Forks
1.1k
Avg merge
2d 21h
Merged PRs (30d)
27

Description

### 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 {
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 {
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 {
// 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

-

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the application.ts TOKEN_SERVICE binding, then trace the @authenticate('jwt') and @authorize flow for the /faqs endpoint to see how the verified profile becomes authorizationCtx.principals. Reproduce the request with a token containing roles and determine where that property is lost; the issue is done when the authorization context exposes the expected roles or the supported limitation is clearly established.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, authentication, authorization
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.