foal icon indicating copy to clipboard operation
foal copied to clipboard

[Social auth] Easy refresh token management

Open LoicPoullain opened this issue 3 years ago • 1 comments

Issue

Currently, Foal allows users to be authenticated through social providers. Through social services, the application can authenticate users with Google, for example, and in return get user information as well as access and refresh tokens to communicate with the Google API.

Issue 1 However, there is no "easy" way to store securely the refresh token for later communication with the API. We can store the refresh token in the session using ctx.session.set('refreshToken', tokens.refresh_token) but then the token is saved in plain text in the database. This can cause security problems if the database is corrupted. For this reason, Foal should provide an easy way to encrypt and decrypt refresh tokens.

Issue 2 Once the refresh token is retrieved, in a later request for example, we still need to take a look at the Google API (or other providers API) to see how to get a new access token. This task could simply be handled by the framework.

Possible solution

Add three new methods encryptRefreshToken, decryptRefreshToken and refreshAndGetAccessToken to each social service.

export class AuthController {
  @dependency
  google: GoogleProvider;

  @dependency
  store: Store;

  @Get('/signin/google')
  redirectToGoogle() {
    return this.google.redirect({
      scopes: [
        'email', 'openid', 'profile',
        // Request access to view the user's YouTube account.
        'https://www.googleapis.com/auth/youtube.readonly'
      ]
    });
  }

  @Get('/signin/google/callback')
  @UseSessions({
    cookie: true,
    csrf: false,
  })
  async handleGoogleRedirection(ctx: Context) {
    const { userInfo } = await this.google.getUserInfo(ctx);

    if (!userInfo.email) {
      throw new Error('Google should have returned an email address.');
    }

    let user = await User.findOne({ email: userInfo.email });

    if (!user) {
      // If the user has not already signed up, then add them to the database.
      user = new User();
      user.email = userInfo.email;
      await user.save();
    }

    ctx.session.setUser(user);
    ctx.session.set('refreshToken', this.google.encryptRefreshToken(tokens.refresh_token))

    return new HttpResponseRedirect('/');
  }

}
export class ApiController {
  @dependency
  google: GoogleProvider;

  @Get('/youtube-activities')
  @UseSessions({ cookie: true })
  async getYouTubeActivities(ctx: Context) {
    const encryptedRefreshToken = ctx.session.get<string|undefined>('refreshToken');
    if (!encryptedRefreshToken) {
      throw new Error('Refresh token not found');
    }

    const refreshToken = this.google.decryptRefreshToken(encryptedRefreshToken);

    const accessToken = await this.google.refreshAndGetAccessToken(refreshToken);

    const response = await fetch('https://www.googleapis.com/youtube/v3/activities', {
      headers: { 'Authorization': `Bearer ${accessToken}` }
    })

    const body = await response.json();

    if (!response.ok) {
      throw new Error(body);
    }

    return new HttpResponseOK(body);
  }

}

LoicPoullain avatar Apr 27 '21 17:04 LoicPoullain

@LoicPoullain Hi! I remember that i've made two methods for encrypt and decrypt a string using a secret from config in AbstractProvider. This could be used for solving issue 1. 👍

/**
   * This function is for encrypt a string using aes-256 and codeVerifierSecret.
   * Notice that init vector base64-encoded is concatenated at start of encrypted message.
   * We'll need init vector to decrypt message.
   * Init vector is 16 bytes length and it base64-encoded is 24 bytes length.
   *
   * @param {string} message - String to encrypt
   */
  private encryptString(message: string): string {

    const hashedSecret = this.getCodeVerifierSecretBuffer();

    // Initiate iv with random bytes
    const initVector = crypto.randomBytes(16);

    // Create cipher
    const cipher = crypto.createCipheriv(this.cryptAlgorithm, hashedSecret, initVector);

    // Encrypt data, concat final
    const data = cipher.update(Buffer.from(message));
    const encryptedMessage = Buffer.concat([data, cipher.final()])

    return `${initVector.toString('base64')}${encryptedMessage.toString('base64')}`
  }

  /**
   * This function is for decrypt a string using aes-256 and codeVerifierSecret
   * encryptedMessage is {iv}{encrypted data}
   *
   * @param {string} encryptedMessage - String to decrypt
   */
    private decryptString(encryptedMessage: string): string {
      const hashedSecret = this.getCodeVerifierSecretBuffer();

      // Get init vector back from encryptedMessage
      const initVector: Buffer = Buffer.from(encryptedMessage.substring(0,24), 'base64'); // original iv is 16 bytes long, so base64 encoded is 24 bytes long
      const message: string = encryptedMessage.substring(24);

      // Create decipher
      const decipher = crypto.createDecipheriv(this.cryptAlgorithm, hashedSecret, initVector);

      // Decrypt data, concat final
      const data = decipher.update(Buffer.from(message, 'base64'));
      const decryptedMessage = Buffer.concat([data, decipher.final()])

      return decryptedMessage.toString()
    }

    private getCodeVerifierSecretBuffer(): Buffer {
      // Get secret from config file or throw an error if not defined
      const codeVerifierSecret = Config.getOrThrow(this.codeVerifierSecretPath, 'string');
      // We create a sha256 hash to ensure that key is 32 bytes long
      return crypto.createHash('sha256').update(codeVerifierSecret).digest();
    }
`
``

LeonardoSalvucci avatar Oct 11 '22 21:10 LeonardoSalvucci