UNPKG

@foal/social

Version:

Social authentication for FoalTS

326 lines (325 loc) 13.2 kB
"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;