@foal/social
Version:
Social authentication for FoalTS
326 lines (325 loc) • 13.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AbstractProvider = exports.TokenError = exports.AuthorizationError = exports.CodeVerifierNotFound = exports.InvalidStateError = void 0;
// std
const url_1 = require("url");
const crypto = require("crypto");
// 3p
const core_1 = require("@foal/core");
/**
* Error thrown if the state does not match.
*
* @export
* @class InvalidStateError
* @extends {Error}
*/
class InvalidStateError extends Error {
name = 'InvalidStateError';
constructor() {
super('Suspicious operation: the state of the callback does not match the state of the authorization request.');
}
}
exports.InvalidStateError = InvalidStateError;
/**
* Error thrown if the (encrypted) code verifier is not found in cookie.
*
* @export
* @class CodeVerifierNotFound
* @extends {Error}
*/
class CodeVerifierNotFound extends Error {
name = 'CodeVerifierNotFound';
constructor() {
super('Suspicious operation: encrypted code verifier not found in cookie.');
}
}
exports.CodeVerifierNotFound = CodeVerifierNotFound;
/**
* Error thrown if the authorization server returns an error.
*
* @export
* @class AuthorizationError
* @extends {Error}
*/
class AuthorizationError extends Error {
error;
errorDescription;
errorUri;
name = 'AuthorizationError';
constructor(error, errorDescription, errorUri) {
super('The authorization server returned an error. Impossible to get an authorization code.\n'
+ `- error: ${error}\n`
+ `- description: ${errorDescription}\n`
+ `- URI: ${errorUri}`);
this.error = error;
this.errorDescription = errorDescription;
this.errorUri = errorUri;
}
}
exports.AuthorizationError = AuthorizationError;
/**
* Error thrown if the token endpoint does not return a 2xx response.
*
* @export
* @class TokenError
* @extends {Error}
*/
class TokenError extends Error {
error;
name = 'TokenError';
constructor(error) {
super('The authorization server returned an error. Impossible to get an access token.\n'
+ JSON.stringify(error, null, 2));
this.error = error;
}
}
exports.TokenError = TokenError;
const STATE_COOKIE_NAME = 'oauth2-state';
const CODE_VERIFIER_COOKIE_NAME = 'oauth2-code-verifier';
/**
* Abstract class that any social provider must inherit from.
*
* @export
* @abstract
* @class AbstractProvider
* @template AuthParameters - Additional parameters to pass to the auth endpoint.
* @template UserInfoParameters - Additional parameters to pass when retrieving user information.
* @template UserInfo - Type of the user information.
*/
class AbstractProvider {
/**
* Default scopes requested by the social provider.
*
* @protected
* @type {string[]}
* @memberof AbstractProvider
*/
defaultScopes = [];
/**
* Character used to separate the scopes in the URL.
*
* @protected
* @type {string}
* @memberof AbstractProvider
*/
scopeSeparator = ' ';
/**
* Enables code flow with PKCE.
*
* @protected
* @type {boolean}
* @memberof AbstractProvider
*/
usePKCE = false;
/**
* Specifies whether to use the plain code verifier string as PKCE code challenge.
*
* @protected
* @type {boolean}
* @memberof AbstractProvider
*/
useCodeVerifierAsCodeChallenge = false;
/**
* Configuration path from which the code verifier secret must be retrieved.
*
* @protected
* @type {boolean}
* @memberof AbstractProvider
*/
codeVerifierSecretPath = 'settings.social.secret.codeVerifierSecret';
/**
* Specifies whether the client ID and client secret must be sent in a Authorization header using Basic scheme.
*
* @protected
* @memberof AbstractProvider
*/
useAuthorizationHeaderForTokenEndpoint = false;
/**
* Algorithm used for the code verifier encryption.
*
* @protected
* @type {string}
* @memberof AbstractProvider
*/
cryptAlgorithm = 'aes-256-ctr';
get config() {
return {
clientId: core_1.Config.getOrThrow(this.configPaths.clientId, 'string'),
clientSecret: core_1.Config.getOrThrow(this.configPaths.clientSecret, 'string'),
redirectUri: core_1.Config.getOrThrow(this.configPaths.redirectUri, 'string')
};
}
/**
* Returns an HttpResponseOK or HttpResponseRedirect object to redirect the user to the social provider's authorization page.
*
* If the isRedirection parameter is undefined or set to false, the function returns an HttpResponseOK object. Its body contains the URL of the consent page.
*
* If the isRedirection parameter is set to true, the function returns an HttpResponseRedirect object.
*
* @param {{ scopes?: string[] }} [{ scopes }={}] - Custom scopes to override the default ones used by the provider.
* @param {{ isRedirection?: boolean }} [{ isRedirection }={}] - If true, the function returns an HttpResponseRedirect object. Otherwise, it returns an HttpResponseOK object.
* @param {AuthParameters} [params] - Additional parameters (specific to the social provider).
* @returns {Promise<HttpResponseOK | HttpResponseRedirect>} The HttpResponseOK or HttpResponseRedirect object.
* @memberof AbstractProvider
*/
async createHttpResponseWithConsentPageUrl({ scopes, isRedirection } = {}, params) {
// Build the authorization URL.
const url = new url_1.URL(this.authEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.config.clientId);
url.searchParams.set('redirect_uri', this.config.redirectUri);
// Add the scopes if any are provided.
const actualScopes = scopes || this.defaultScopes;
if (actualScopes.length > 0) {
url.searchParams.set('scope', actualScopes.join(this.scopeSeparator));
}
// Generate a state to protect against CSRF attacks.
const state = await this.getState();
url.searchParams.set('state', state);
// Add extra parameters to the URL.
if (params) {
for (const key in params) {
url.searchParams.set(key, params[key]);
}
}
// We use a base64url-encoded random token making OAuth2 PKCE spec compliant - see https://datatracker.ietf.org/doc/html/rfc7636#appendix-B for more information
const codeVerifier = await (0, core_1.generateToken)();
if (this.usePKCE) {
const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64');
url.searchParams.set('code_challenge', this.useCodeVerifierAsCodeChallenge ? codeVerifier : (0, core_1.convertBase64ToBase64url)(hash));
url.searchParams.set('code_challenge_method', this.useCodeVerifierAsCodeChallenge ? 'plain' : 'S256');
}
const response = isRedirection ? new core_1.HttpResponseRedirect(url.href) : new core_1.HttpResponseOK({ consentPageUrl: url.href });
const cookieOptions = {
httpOnly: true,
maxAge: 300,
path: '/',
secure: core_1.Config.get('settings.social.cookie.secure', 'boolean', false)
};
const cookieDomain = core_1.Config.get('settings.social.cookie.domain', 'string');
if (cookieDomain) {
cookieOptions.domain = cookieDomain;
}
// Add Code Challenge COOKIE for token request
if (this.usePKCE) {
// Encrypt this code_challenge cookie for security reasons
response.setCookie(CODE_VERIFIER_COOKIE_NAME, this.encryptString(codeVerifier), cookieOptions);
}
// Return a redirection response with the state as cookie.
return response
.setCookie(STATE_COOKIE_NAME, state, cookieOptions);
}
/**
* Function to use in the controller method that handles the provider redirection.
*
* It returns an access token.
*
* @param {Context} ctx - The request context.
* @returns {Promise<SocialTokens>} The tokens (it contains at least an access token).
* @memberof AbstractProvider
*/
async getTokens(ctx) {
if (ctx.request.query.state !== ctx.request.cookies[STATE_COOKIE_NAME]) {
throw new InvalidStateError();
}
if (ctx.request.query.error) {
throw new AuthorizationError(ctx.request.query.error, ctx.request.query.error_description, ctx.request.query.error_uri);
}
const params = new url_1.URLSearchParams();
params.set('grant_type', 'authorization_code');
params.set('code', ctx.request.query.code || '');
params.set('redirect_uri', this.config.redirectUri);
if (!this.useAuthorizationHeaderForTokenEndpoint) {
params.set('client_id', this.config.clientId);
params.set('client_secret', this.config.clientSecret);
}
if (this.usePKCE) {
const encryptedCodeVerifier = ctx.request.cookies[CODE_VERIFIER_COOKIE_NAME];
if (!encryptedCodeVerifier) {
throw new CodeVerifierNotFound();
}
const codeVerifier = this.decryptString(encryptedCodeVerifier);
params.set('code_verifier', codeVerifier);
}
const headers = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
};
if (this.useAuthorizationHeaderForTokenEndpoint) {
const auth = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
headers.Authorization = `Basic ${auth}`;
}
const response = await fetch(this.tokenEndpoint, {
body: params,
headers,
method: 'POST',
});
const body = await response.json();
if (!response.ok) {
throw new TokenError(body);
}
return body;
}
/**
* Function to use in the controller method that handles the provider redirection.
*
* It retrieves the access token as well as the user information.
*
* @param {Context} ctx - The request context.
* @param {UserInfoParameters} [params] - Additional parameters to pass to the function.
* @returns {Promise<UserInfoAndTokens<UserInfo>>} The access token and the user information
* @memberof AbstractProvider
*/
async getUserInfo(ctx, params) {
const tokens = await this.getTokens(ctx);
const userInfo = await this.getUserInfoFromTokens(tokens, params);
return { userInfo, tokens };
}
async getState() {
return (0, core_1.generateToken)();
}
/**
* 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
*/
encryptString(message) {
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
*/
decryptString(encryptedMessage) {
const hashedSecret = this.getCodeVerifierSecretBuffer();
// Get init vector back from encryptedMessage
const initVector = Buffer.from(encryptedMessage.substring(0, 24), 'base64'); // original iv is 16 bytes long, so base64 encoded is 24 bytes long
const message = 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();
}
getCodeVerifierSecretBuffer() {
// Get secret from config file or throw an error if not defined
const codeVerifierSecret = core_1.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();
}
}
exports.AbstractProvider = AbstractProvider;