/* eslint-disable camelcase */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-len */ import { AuthSpec } from '../schema/WebApiSchema.js'; import { PluginContext } from '../types/PluginContext.js'; import { SqupContext } from '../types/SqupContext.js'; import type { } from '../types/fetch.js'; // NodeJS v18 has fetch in core, but no types :( const oauthButtonName = 'oauth2AuthCodeSignIn'; // Must match the name of the oAuth2 button in ui.json const encryptedFieldNames = ['access_token', 'refresh_token']; /** * Mutates the supplied headers to effect the plugin's configured authorization and returns any * args that need to be added to the query */ export async function processOAuth2( headers: { [x: string]: any }, refresh: boolean, auth: AuthSpec, pluginContext: PluginContext, squpContext: SqupContext): Promise<{ key: string, value: any }[]> { const pluginConfig = pluginContext.config.pluginConfig; const authArgs: { key: string, value: any }[] = []; const now = Date.now(); if (refresh || !pluginConfig[oauthButtonName]?.access_token || pluginConfig[oauthButtonName]?.expiryTime && pluginConfig[oauthButtonName].expiryTime <= now) { // Need to refresh the token squpContext.log.debug('oauth2: processAuth calls refreshOAuth'); await refreshOAuth(auth, pluginContext, squpContext); } const accessToken = pluginConfig[oauthButtonName]?.access_token; if (refresh && !accessToken) { squpContext.report.error('No access token was available, please re-authenticate in the Data Source Management UI'); } if (auth.sendTokenInParameters) { ['token', 'access-token', 'oauth_token'].forEach((key) => addArg(authArgs, { key, value: accessToken })); } else { headers['Authorization'] = `Bearer ${accessToken}`; } return authArgs; } /** * Function to clear any previous token (e.g. before testing a new config) * * @param {*} context */ export function clearAuth( auth: AuthSpec, pluginContext: PluginContext, squpContext: SqupContext) { const pluginConfig = pluginContext.config.pluginConfig; if (auth.oauth2GrantType !== 'authCode' && pluginConfig[oauthButtonName]) { // If we're not using authCode flow and there's a token already present, clear it down (we can't // clear it here for authCode flow because the plugin config modal may just have filled it in // with use of the sign-in button) pluginConfig[oauthButtonName].access_token = undefined; pluginConfig[oauthButtonName].token_type = undefined; pluginConfig[oauthButtonName].expires_in = undefined; pluginConfig[oauthButtonName].scope = undefined; squpContext.patchConfig(oauthButtonName, pluginConfig[oauthButtonName], ['access_token', 'refresh_token']); } pluginContext.oauthErrorMessage = ''; } /** * This function should be called when a request is being executed using OAuth authentication and * there is no usable access token. * * @param {*} context */ async function refreshOAuth( auth: AuthSpec, pluginContext: PluginContext, squpContext: SqupContext) { const pluginConfig = pluginContext.config.pluginConfig; pluginContext.oauthErrorMessage = ''; let obtainedNewToken = false; squpContext.log.debug('oauth2: refreshOAuth starts'); if (pluginConfig[oauthButtonName]?.refresh_token) { // Use the refresh token squpContext.log.debug('oauth2: using refresh token'); pluginContext.oauthErrorMessage = await getToken({ grant_type: 'refresh_token', refresh_token: pluginConfig[oauthButtonName].refresh_token }, auth, pluginContext, squpContext); if (pluginContext.oauthErrorMessage) { squpContext.log.debug(pluginContext.oauthErrorMessage); } else { squpContext.log.debug('oauth2: got new token with refresh token'); obtainedNewToken = true; } } if (!obtainedNewToken) { // Try getting a new access token using first principles switch (auth.oauth2GrantType) { case 'authCode': { squpContext.report.error('Unable to refresh OAuth access token, please re-authenticate in the Data Source Management UI'); break; } case 'password': { squpContext.log.debug('oauth2: getting token with password'); pluginContext.oauthErrorMessage = await getToken({ grant_type: 'password', username: auth.username, password: auth.password }, auth, pluginContext, squpContext); break; } case 'clientCredentials': { squpContext.log.debug('oauth2: getting token with client credentials'); pluginContext.oauthErrorMessage = await getToken({ grant_type: 'client_credentials' }, auth, pluginContext, squpContext); break; } default: { squpContext.report.error(`Invalid grant type: ${auth.oauth2GrantType}`); break; } } } if (pluginContext.oauthErrorMessage) { squpContext.report.error(pluginContext.oauthErrorMessage); } } /** * This function is used to get a fresh access token for a request using OAuth authentication. * It can be used at different points in several auth flows and the specific details of each * are supplied by the called in the args parameter. */ async function getToken( args: { [x: string]: any }, auth: AuthSpec, pluginContext: PluginContext, squpContext: SqupContext) { const pluginConfig = pluginContext.config.pluginConfig; const headers: { [x: string]: any } = {}; let bodyArgs: { [x: string]: any } | undefined = undefined; const isRefresh = args['grant_type'] === 'refresh_token'; if (auth.oauth2Scope) { args['scope'] = auth.oauth2Scope; } switch (auth.oauth2ClientSecretLocationDuringAuth) { case 'query': { args['client_id'] = auth.oauth2ClientId; if (auth.oauth2ClientSecret) { args['client_secret'] = auth.oauth2ClientSecret; } break; } case 'header': { const oauth2ClientId = (auth.oauth2ClientId); const oauth2ClientSecret = (auth.oauth2ClientSecret); headers['Authorization'] = `Basic ${Buffer.from(`${oauth2ClientId}:${oauth2ClientSecret}`).toString('base64')}`; bodyArgs = { ...args }; args = {}; break; } case 'body': { headers['Content-Type'] = 'application/x-www-form-urlencoded'; bodyArgs = { ...args, client_id: auth.oauth2ClientId }; if (auth.oauth2ClientSecret) { bodyArgs['client_secret'] = auth.oauth2ClientSecret; } args = {}; break; } default: { // } } const tokenUrl = new URL(auth.oauth2TokenUrl ?? ''); Array.from(tokenUrl.searchParams.entries()).forEach(([key, value]) => (args[key] = value)); const query = (new URLSearchParams(args)).toString(); const queryString = query ? `?${query}` : ''; const url = `${tokenUrl.origin}${tokenUrl.pathname}${queryString}${tokenUrl.hash}`; const body = bodyArgs ? new URLSearchParams(bodyArgs).toString() : undefined; const opts = { method: 'POST', headers, body }; headers['Accept'] = 'application/json'; squpContext.log.debug(`oauth2 getToken(): Calling token URL: ${url}, method: '${opts.method}'`); let resp: Response | undefined = undefined; try { resp = await fetch(url, opts); squpContext.log.debug(`oauth2 getToken(): Call on token URL: ${url} returned ${resp.status} (${resp.statusText})`); if (resp.status !== 200) { return `Request to ${auth.oauth2TokenUrl} failed with status ${resp.status}`; } } catch (err: any) { squpContext.log.debug(`Request to ${auth.oauth2TokenUrl} threw exception ${err}`); return `Request to ${auth.oauth2TokenUrl} threw exception ${err.cause?.message ?? err.message}`; } const now = Date.now(); const payload: any = await resp.json(); if (typeof payload === 'object' && payload !== null) { const propInfo = Object.entries(payload).map(([key, val]) => `${key}: ${encryptedFieldNames.includes(key) && typeof val === 'string' ? `secret of length ${val.length}` : val}`); squpContext.log.debug(`oauth2 getToken(): Call on token URL: ${url} returned payload with properties: ${propInfo}`); if (!payload.access_token) { const noAccess = 'Token URL failed to return access_token'; squpContext.log.debug(`oauth2 getToken(): returning "${noAccess}"`); return noAccess; } if (!isRefresh && (payload.expires_in && !payload.refresh_token)) { const noRefresh = 'Token URL failed to return refresh_token; ensure you request offline access in your authorization request'; squpContext.log.debug(`oauth2 getToken(): returning "${noRefresh}"`); return noRefresh; } } else { squpContext.log.debug('oauth2 getToken(): returning "Token URL returned invalid response"'); return 'Token URL returned invalid response'; } squpContext.log.debug('oauth2 getToken(): got acceptable token'); pluginConfig[oauthButtonName] = pluginConfig[oauthButtonName] ?? {}; pluginConfig[oauthButtonName].access_token = payload.access_token; pluginConfig[oauthButtonName].token_type = payload.token_type; pluginConfig[oauthButtonName].expires_in = payload.expires_in; pluginConfig[oauthButtonName].scope = payload.scope; if (typeof pluginConfig[oauthButtonName].expires_in === 'number') { pluginConfig[oauthButtonName].expiryTime = now + 1000 * pluginConfig[oauthButtonName].expires_in; } if (payload.refresh_token) { pluginConfig[oauthButtonName].refresh_token = payload.refresh_token; } let userName = null; if (payload.id_token) { try { const userInfo = JSON.parse(Buffer.from(payload.id_token.split('.')[1], 'base64').toString('ascii')); squpContext.log.info('oauth2 getToken() got userInfo', { userInfo }); userName = userInfo.email; } catch {/* ignore */} } pluginConfig[oauthButtonName].userName = userName; squpContext.patchConfig(oauthButtonName, pluginConfig[oauthButtonName], ['access_token', 'refresh_token']); return null; } // /** // * This function is called by Cloud platform code when the user hits the OAuth2 "Sign in" button in the // * plugin config UI. // * // * @param {*} context // * @returns The authorization URL the client should redirect the user to for them to authenticate themselves // * with the third-party auth-server // */ // export async function OAuthBegin(context) { // const pluginConfig = pluginContext.config; // const oAuth2Name = context.oAuth2Config.oAuth2Name; // context.log.debug(`OAuthBegin called for ${oAuth2Name}, oauth2AuthUrl="${pluginConfig.oauth2AuthUrl}"`); // // Clear any previous tokens // clearAuth(context); // const authUrl = new URL(pluginConfig.oauth2AuthUrl); // const authArgs = { // response_type: 'code', // client_id: pluginConfig.oauth2ClientId, // state: pluginConfig[oAuth2Name].state, // scope: pluginConfig.oauth2Scope, // redirect_uri: pluginConfig[oAuth2Name].redirectUri // }; // if (Array.isArray(pluginConfig.oauth2AuthExtraArgs)) { // pluginConfig.oauth2AuthExtraArgs.forEach(({ key, value }) => authArgs[key] = value); // } // const urlArgs = authUrl.searchParams ? Array.from(authUrl.searchParams.entries()) : []; // urlArgs.forEach(([ key, value ]) => authArgs[key] = value); // const queryString = new URLSearchParams(authArgs); // const url = `${authUrl.origin}${authUrl.pathname}?${queryString}${authUrl.hash}`; // // The 'state' value is no longer needed in the plugin config once it has been sent to the auth server // pluginConfig[oAuth2Name].state = undefined; // context.patchConfig(oAuth2Name, pluginConfig[oAuth2Name], encryptedFieldNames); // context.log.debug(`OAuthBegin returns ${url}`); // return url; // } // /** // * This function is called after the user has authenticated in the browser with the third-party auth-server. // * The query args used when the third-party auth-server redirected to our cloud product should contain an // * auth code (if authentication was successful) or some sort of error message. // * // * @param {*} context // * @returns // */ // export async function OAuthCodeResponse(context) { // const pluginConfig = pluginContext.config; // const oAuth2Name = context.oAuth2Config.oAuth2Name; // const queryArgs = pluginConfig[oAuth2Name].queryArgs; // context.log.debug(`OAuthCodeResponse called for ${oAuth2Name}`, { queryArgs }); // if (queryArgs['error'] || !queryArgs['code']) { // const errors = [(queryArgs['error'] || 'No auth. code received') + (queryArgs['error_subcode'] ? ` - (${queryArgs['error_subcode']})` : '')]; // if (queryArgs['error_description']) { // errors.push(decodeURIComponent(queryArgs['error_description'])); // } // pluginConfig[oAuth2Name].loginStatus = 'error'; // pluginConfig[oAuth2Name].errors = errors; // pluginConfig[oAuth2Name].userName = undefined; // } else { // const errorMessage = await getToken({ // grant_type: 'authorization_code', // code: queryArgs['code'], // authZ code // redirect_uri: pluginConfig[oAuth2Name].redirectUri // }, context); // if (errorMessage) { // pluginConfig[oAuth2Name].loginStatus = 'error'; // pluginConfig[oAuth2Name].errors = [errorMessage]; // } else { // pluginConfig[oAuth2Name].loginStatus = 'loggedIn'; // pluginConfig[oAuth2Name].errors = []; // } // } // pluginConfig[oAuth2Name].queryArgs = undefined; // context.patchConfig(oAuth2Name, pluginConfig[oAuth2Name], encryptedFieldNames); // return true; // } /** * Add to an arg collection * * @param {{ key, value }[]} args * @param {{ key, value }} arg */ function addArg(args: { key: string, value: any }[], arg: { key: string, value: any }) { const x = args.findIndex((a) => a.key === arg.key); if (x < 0) { args.push(arg); } else { args[x].value = arg.value; } }