foal
foal copied to clipboard
[Social auth] Easy refresh token management
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 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();
}
`
``