Loopback-next: Best practice? Optional (single) endpoint authentication

Created on 10 Mar 2020  路  10Comments  路  Source: strongloop/loopback-next

Simply put, how is it best to create an endpoint that allows both anonymous and authenticated access. Should this pattern be avoided? Is there a better way?

Use case: the same interface to create a comment anonymously or with username appended.

Example (NOT FOR PRODUCTION):

  @authenticate('jwt-optional')
  @get('/whoami')
  @inject(SecurityBindings.USER) currentUser: UserProfile,
  whoAmI(): string {
    if (!currentUser.id) {
      return 'You are anonymous';
    }
    return this.userProfile[securityId];
  }

Optional JWT Strategy:

export class JWTOptionalAuthenticationStrategy
  implements AuthenticationStrategy {
  name = 'jwt-optional';

  constructor(
    @inject(TokenServiceBindings.TOKEN_SERVICE)
    public tokenService: TokenService,
  ) {}

  async authenticate(request: Request): Promise<UserProfile | undefined> {
    const token: string | undefined = this.extractCredentials(request);
    const userProfile: UserProfile = token
      ? await this.tokenService.verifyToken(token)
      : {[securityId]: ''};
    return userProfile;
  }

  extractCredentials(request: Request): string | undefined {
    if (!request.headers.authorization) return undefined;

    // for example : Bearer xxx.yyy.zzz
    const authHeaderValue = request.headers.authorization;

    if (!authHeaderValue.startsWith('Bearer')) {
      throw new HttpErrors.Unauthorized(
        `Authorization header is not of type 'Bearer'.`,
      );
    }

    //split the string into 2 parts : 'Bearer ' and the `xxx.yyy.zzz`
    const parts = authHeaderValue.split(' ');
    if (parts.length !== 2)
      throw new HttpErrors.Unauthorized(
        `Authorization header value has too many parts. It must follow the pattern: 'Bearer xx.yy.zz' where xx.yy.zz is a valid JWT token.`,
      );
    const token = parts[1];

    return token;
  }
}

tag: @emonddr

Authentication question

All 10 comments

Umm. I would think an endpoint that requires authentication to protected resources shouldn't allow anyone on the internet to access that specific resource, so I am having trouble understanding the special scenario for such a need. Perhaps you can elaborate? thx.

@raymondfeng , do you have some thoughts on this? thx.

Keyword being optional rather than required on the standard authentication via say JWT. A more sensible use-case would be using a separate token(say emailed to user for password reset) alongside the JWT bearer token. For example:

  @authenticate('jwt-optional')
  @patch('/users/{id}/password')
  async passwordUpdate(
    @requestBody({ // new password }}
    @inject(SecurityBindings.USER) currentUser: UserProfile,
    @param.path.string('id') id: string,
    @param.query.string('key') keyId?: string,
): User {
    if (!currentUser.id) {
      const keyFound = getKey(keyId);
      if (!keyFound) {
        // throw auth error
      }
    }

    const userId = currentUser.id ?? keyFound.id;
    // ... update user

    // ... return updated user (simplified, assuming password returned for example)
  }

Would this be recommended to completely encapsulate in an auth strategy? Or used with extreme caution.... Thanks for your advice.

@bajtos or @raymondfeng , what is your opinion on this scenario?

I'm thinking the following would be a move in a better direction... Would it be an idea to use SecurityBindings.SUBJECT to hold a one-time-key for a request? If anyone has an example to set that up I'd be a happy chap. :)

export class JWTOrKeyAuthenticationStrategy implements AuthenticationStrategy {
  name = 'jwt-key';

  constructor(
    @inject(TokenServiceBindings.TOKEN_SERVICE)
    public tokenService: TokenService,
    @inject(KeyServiceBindings.KEY_SERVICE)
    public keyService: AccountKeyService,
  ) {}

  async authenticate(request: Request): Promise<UserProfile | undefined> {
    const token: string | undefined = this.extractCredentials(request);
    const key: string | undefined = this.extractKey(request);

    if (!token && !key) {
      throw new HttpErrors.Unauthorized(
        'Authorization cannot find JWT or Key.',
      );
    }

    const accKey = key ? await this.keyService.getKey(key) : undefined;

    const userProfile: UserProfile = {
      [securityId]: '',
      ...(token && (await this.tokenService.verifyToken(token))),
      ...(accKey && {reqKey: accKey}),
    };

    return userProfile;
  }

  extractCredentials(request: Request): string | undefined {
    if (!request.headers.authorization) return undefined;

    const authHeaderValue = request.headers.authorization;

    if (!authHeaderValue.startsWith('Bearer')) return undefined;

    const parts = authHeaderValue.split(' ');
    if (parts.length !== 2) return undefined;
    const token = parts[1];

    return token;
  }

  extractKey(request: Request): string | undefined {
    if (!request.query['key']) return undefined;

    const key = request.query['key'];

    // account key is uuid with 36 chars
    if (key.length !== 36) {
      return undefined;
    }

    return key;
  }
}

@dougal83 Do you use authentication for personalization in this case?

@dougal83 Do you use authentication for personalization in this case?

@raymondfeng Yes, that was the idea. Something non essential that could pull up customised data if available for the user.

@dougal83 see issue https://github.com/strongloop/loopback-next/issues/5310 and PR https://github.com/strongloop/loopback-next/pull/5735, would that solve your problem here?

In your case, you can have 2 strategies separately JWTStrategy and KeyStrategy, and decorate your endpoint with

@authenticate(['jwt', 'key'])

@jannyHou Yes, it looks like it will solve the issue supplying multiple strategies. Feel free to close this issue at your convenience.

Closing per the above comment.

Was this page helpful?
0 / 5 - 0 ratings