Source: oidcprovider.js

const Utils = require('@openeo/js-commons/src/utils');
const AuthProvider = require('./authprovider');
const Environment = require('./env');
const Oidc = require('oidc-client');

/**
 * The Authentication Provider for OpenID Connect.
 * 
 * See the openid-connect-popup.html and openid-connect-redirect.html files in
 * the `/examples/oidc` folder for usage examples in the browser.
 * 
 * If you want to implement OIDC in a non-browser environment, you can override 
 * the OidcProvider or AuthProvider classes with custom behavior.
 * In this case you must provide a function that creates your new class to the
 * `Connection.setOidcProviderFactory()` method.
 * 
 * @augments AuthProvider
 * @see Connection#setOidcProviderFactory
 */
class OidcProvider extends AuthProvider {

	/**
	 * Checks whether the required OIDC client library `openid-client-js` is available.
	 * 
	 * @static
	 * @returns {boolean}
	 */
	static isSupported() {
		return Utils.isObject(Oidc) && Boolean(Oidc.UserManager);
	}

	/**
	 * Finishes the OpenID Connect sign in (authentication) workflow.
	 * 
	 * Must be called in the page that OpenID Connect redirects to after logging in.
	 * 
	 * Supported only in Browser environments.
	 * 
	 * @async
	 * @static
	 * @param {OidcProvider} provider - A OIDC provider to assign the user to.
	 * @param {object.<string, *>} [options={}] - Object with additional options.
	 * @returns {Promise<?Oidc.User>} For uiMethod = 'redirect' only: OIDC User
	 * @throws {Error}
	 * @see https://github.com/IdentityModel/oidc-client-js/wiki#other-optional-settings
	 */
	static async signinCallback(provider = null, options = {}) {
		let url = Environment.getUrl();
		if (!provider) {
			// No provider options available, try to detect response mode from URL
			provider = new OidcProvider(null, {});
			provider.setGrant(url.includes('?') ? 'authorization_code+pkce' : 'implicit');
		}
		let providerOptions = provider.getOptions(options);
		let oidc = new Oidc.UserManager(providerOptions);
		await oidc.clearStaleState();
		return await oidc.signinCallback(url);
	}

	/**
	 * Creates a new OidcProvider instance to authenticate using OpenID Connect.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {OidcProviderMeta} options - OpenID Connect Provider details as returned by the API.
	 */
	constructor(connection, options) {
		super("oidc", connection, options);

		this.manager = null;
		this.listeners = {};

		/**
		 * The authenticated OIDC user.
		 * 
		 * @type {Oidc.User}
		 */
		this.user = null;
		
		/**
		 * The client ID to use for authentication.
		 * 
		 * @type {string | null}
		 */
		this.clientId = null;

		/**
		 * The client secret to use for authentication.
		 * 
		 * Only used for the `client_credentials` grant type.
		 * 
		 * @type {string | null}
		 */
		this.clientSecret = null;

		/**
		 * The grant type (flow) to use for this provider.
		 * 
		 * Either "authorization_code+pkce" (default), "implicit" or "client_credentials"
		 * 
		 * @type {string}
		 */
		this.grant = "authorization_code+pkce"; // Set this before calling detectDefaultClient

		/**
		 * The issuer, i.e. the link to the identity provider.
		 * 
		 * @type {string}
		 */
		this.issuer = options.issuer || "";

		/**
		 * The scopes to be requested.
		 * 
		 * @type {Array.<string>}
		 */
		this.scopes = Array.isArray(options.scopes) && options.scopes.length > 0 ? options.scopes : ['openid'];

		/**
		 * The scope that is used to request a refresh token.
		 * 
		 * @type {string}
		 */
		this.refreshTokenScope = "offline_access";

		/**
		 * Any additional links.
		 * 
		 * 
		 * @type {Array.<Link>}
		 */
		this.links = Array.isArray(options.links) ? options.links : [];

		/**
		 * The default clients made available by the back-end.
		 * 
		 * @type {Array.<OidcClient>}
		 */
		this.defaultClients = Array.isArray(options.default_clients) ? options.default_clients : [];

		/**
		 * Additional parameters to include in authorization requests.
		 * 
		 * As defined by the API, these parameters MUST be included when
		 * requesting the authorization endpoint.
		 * 
		 * @type {object.<string, *>}
		 */
		this.authorizationParameters = Utils.isObject(options.authorization_parameters) ? options.authorization_parameters : {};

		/**
		 * The detected default Client.
		 * 
		 * @type {OidcClient}
		 */
		this.defaultClient = this.detectDefaultClient();

		/**
		 * The cached OpenID Connect well-known configuration document.
		 * 
		 * @type {object.<string, *> | null}
		 */
		this.wellKnownDocument = null;
	}

	/**
	 * Adds a listener to one of the following events:
	 * 
	 * - AccessTokenExpiring: Raised prior to the access token expiring.
	 * - AccessTokenExpired: Raised after the access token has expired.
	 * - SilentRenewError: Raised when the automatic silent renew has failed.
	 * 
	 * @param {string} event 
	 * @param {Function} callback
	 * @param {string} [scope="default"]
	 */
	addListener(event, callback, scope = 'default') {
		this.manager.events[`add${event}`](callback);
		this.listeners[`${scope}:${event}`] = callback;
	}

	/**
	 * Removes the listener for the given event that has been set with addListener.
	 * 
	 * @param {string} event 
	 * @param {string} [scope="default"]
	 * @see OidcProvider#addListener
	 */
	removeListener(event, scope = 'default') {
		this.manager.events[`remove${event}`](this.listeners[event]);
		delete this.listeners[`${scope}:${event}`];
	}

	/**
	 * Authenticate with OpenID Connect (OIDC).
	 * 
	 * Supported in Browser environments for `authorization_code+pkce` and `implicit` grants.
	 * The `client_credentials` grant is supported in all environments.
	 * 
	 * @async
	 * @param {object.<string, *>} [options={}] - Object with authentication options.
	 * @param {boolean} [requestRefreshToken=false] - If set to `true`, adds a scope to request a refresh token.
	 * @returns {Promise<void>}
	 * @throws {Error}
	 * @see https://github.com/IdentityModel/oidc-client-js/wiki#other-optional-settings
	 * @see {OidcProvider#refreshTokenScope}
	 */
	async login(options = {}, requestRefreshToken = false) {
		if (!this.issuer || typeof this.issuer !== 'string') {
			throw new Error("No Issuer URL available for OpenID Connect");
		}

		if (this.grant === 'client_credentials') {
			return await this.loginClientCredentials();
		}

		this.manager = new Oidc.UserManager(this.getOptions(options, requestRefreshToken));
		this.addListener('UserLoaded', async () => this.setUser(await this.manager.getUser()), 'js-client');
		this.addListener('AccessTokenExpired', () => this.setUser(null), 'js-client');
		if (OidcProvider.uiMethod === 'popup') {
			await this.manager.signinPopup();
		}
		else {
			await this.manager.signinRedirect();
		}
	}

	/**
	 * Authenticate using the OIDC Client Credentials grant.
	 * 
	 * Requires `clientId` and `clientSecret` to be set.
	 * This flow does not use the oidc-client library and works in all environments.
	 * 
	 * @async
	 * @protected
	 * @returns {Promise<void>}
	 * @throws {Error}
	 */
	async loginClientCredentials() {
		if (!this.clientId || !this.clientSecret) {
			throw new Error("Client ID and Client Secret are required for the client credentials flow");
		}

		let tokenEndpoint = await this.getTokenEndpoint();

		let params = new URLSearchParams();
		params.append('grant_type', 'client_credentials');
		params.append('client_id', this.clientId);
		params.append('client_secret', this.clientSecret);
		params.append('scope', this.scopes.join(' '));

		let response = await Environment.axios.post(tokenEndpoint, params.toString(), {
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
		});

		let data = response.data;
		let user = new Oidc.User({
			access_token: data.access_token,
			token_type: data.token_type || 'Bearer',
			scope: data.scope || this.scopes.join(' '),
			expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
			profile: {}
		});
		this.setUser(user);
	}

	/**
	 * Retrieves the OpenID Connect well-known configuration document.
	 * 
	 * @async
	 * @returns {Promise<object.<str, *>> | null} The well-known configuration document, or `null` if the issuer URL is not set.
	 */
	async getWellKnownDocument() {
		if (!this.issuer || typeof this.issuer !== 'string') {
			return null;
		}
		if (this.wellKnownDocument === null) {
			const authority = this.issuer.replace('/.well-known/openid-configuration', '');
			const discoveryUrl = authority + '/.well-known/openid-configuration';
			const response = await Environment.axios.get(discoveryUrl);
			this.wellKnownDocument = response.data;
		}
		return this.wellKnownDocument;
	}

	/**
	 * Discovers the token endpoint from the OpenID Connect issuer.
	 * 
	 * @async
	 * @protected
	 * @returns {Promise<string>} The token endpoint URL.
	 * @throws {Error}
	 */
	async getTokenEndpoint() {
		const wellKnown = await this.getWellKnownDocument();
		if (!Utils.isObject(wellKnown) || !wellKnown.token_endpoint) {
			throw new Error("Unable to discover token endpoint from issuer");
		}
		return wellKnown.token_endpoint;
	}

	/**
	 * Checks whether the OpenID Connect provider supports the Client Credentials grant.
	 * 
	 * @async
	 * @returns {Promise<boolean|null>} `true` if the Client Credentials grant is supported, `false` otherwise. `null` if unknown.
	 */
	async supportsClientCredentials() {
		try {
			const wellKnown = await this.getWellKnownDocument();
			if (!Utils.isObject(wellKnown) || !Array.isArray(wellKnown.grant_types_supported)) {
				return null;
			}
			return wellKnown.grant_types_supported.includes('client_credentials');
		} catch (error) {
			return null;
		}
	}

	/**
	 * Restores a previously established OIDC session from storage.
	 * 
	 * Not supported for the `client_credentials` grant as credentials
	 * are not persisted. Use `login()` to re-authenticate instead.
	 * 
	 * @async
	 * @param {object.<string, *>} [options={}] - Additional options passed to the OIDC UserManager.
	 * @returns {Promise<boolean>} `true` if the session could be resumed, `false` otherwise.
	 * @see https://github.com/IdentityModel/oidc-client-js/wiki#usermanager
	 */
	async resume(options = {}) {
		if (this.grant === 'client_credentials') {
			return false;
		}

		this.manager = new Oidc.UserManager(this.getOptions(options));
		this.addListener('UserLoaded', async () => this.setUser(await this.manager.getUser()), 'js-client');
		this.addListener('AccessTokenExpired', () => this.setUser(null), 'js-client');

		let user = await this.manager.getUser();
		if (user && user.expired && user.refresh_token) {
			user = await this.manager.signinSilent();
		}

		if (user && !user.expired) {
			this.setUser(user);
			return true;
		}

		return false;
	}

	/**
	 * Logout from the established session.
	 * 
	 * @async
	 */
	async logout() {
		if (this.grant === 'client_credentials') {
			super.logout();
			this.setUser(null);
			return;
		}

		if (this.manager !== null) {
			try {
				if (OidcProvider.uiMethod === 'popup') {
					await this.manager.signoutPopup();
				}
				else {
					await this.manager.signoutRedirect({
						post_logout_redirect_uri: Environment.getUrl()
					});
				}
			} catch (error) {
				console.warn(error);
			}
			super.logout();
			this.removeListener('UserLoaded', 'js-client');
			this.removeListener('AccessTokenExpired', 'js-client');
			this.manager = null;
			this.setUser(null);
		}
	}

	/**
	 * Returns the options for the OIDC client library.
	 * 
	 * Options can be overridden by custom options via the options parameter.
	 * 
	 * @protected
	 * @param {object.<string, *>} options 
	 * @param {boolean} [requestRefreshToken=false] - If set to `true`, adds a scope to request a refresh token.
	 * @returns {object.<string, *>}
	 * @see {OidcProvider#refreshTokenScope}
	 */
	getOptions(options = {}, requestRefreshToken = false) {
		let response_type = this.getResponseType();
		let scope = this.scopes.slice(0);
		if (requestRefreshToken && !scope.includes(this.refreshTokenScope)) {
			scope.push(this.refreshTokenScope);
		}

		return Object.assign({
			client_id: this.clientId,
			redirect_uri: OidcProvider.redirectUrl,
			authority: this.issuer.replace('/.well-known/openid-configuration', ''),
			scope: scope.join(' '),
			validateSubOnSilentRenew: true,
			response_type,
			response_mode: response_type.includes('code') ? 'query' : 'fragment',
			extraQueryParams: this.authorizationParameters
		}, options);
	}

	/**
	 * Get the response_type based on the grant type.
	 * 
	 * @protected
	 * @returns {string}
	 * @throws {Error}
	 */
	getResponseType() {
		switch(this.grant) {
			case 'authorization_code+pkce':
				return 'code';
			case 'implicit':
				return 'token id_token';
			case 'client_credentials':
				return null;
			default:
				throw new Error('Grant Type not supported');
		}
	}

	/**
	 * Sets the grant type (flow) used for OIDC authentication.
	 * 
	 * @param {string} grant - Grant Type
	 * @throws {Error}
	 */
	setGrant(grant) { // 
		switch(grant) {
			case 'authorization_code+pkce':
			case 'implicit':
			case 'client_credentials':
				this.grant = grant;
				break;
			default:
				throw new Error('Grant Type not supported');
		}
	}

	/**
	 * Sets the Client ID for OIDC authentication.
	 * 
	 * This may override a detected default client ID.
	 * 
	 * @param {string | null} clientId
	 */
	setClientId(clientId) {
		this.clientId = clientId;
	}

	/**
	 * Sets the Client Secret for OIDC authentication.
	 * 
	 * Only used for the `client_credentials` grant type.
	 * 
	 * @param {string | null} clientSecret
	 */
	setClientSecret(clientSecret) {
		this.clientSecret = clientSecret;
	}

	/**
	 * Sets the OIDC User.
	 * 
	 * @see https://github.com/IdentityModel/oidc-client-js/wiki#user
	 * @param {Oidc.User | null} user - The OIDC User. Passing `null` resets OIDC authentication details.
	 */
	setUser(user) {
		if (!user) {
			this.user = null;
			this.setToken(null);
		}
		else {
			this.user = user;
			this.setToken(user.access_token);
		}
	}

	/**
	 * Returns a display name for the authenticated user.
	 * 
	 * For the `client_credentials` grant, returns a name based on the client ID.
	 * 
	 * @returns {string?} Name of the user or `null`
	 */
	getDisplayName() {
		if (this.user && Utils.isObject(this.user.profile)) {
			return this.user.profile.name || this.user.profile.preferred_username || this.user.profile.email || null;
		}
		if (this.grant === 'client_credentials' && this.clientId) {
			let id = this.clientId;
			if (id.length > 15) {
				id = id.slice(0, 5) + '\u2026' + id.slice(-5);
			}
			return `Client ${id}`;
		}
		return null;
	}

	/**
	 * Detects the default OIDC client ID for the given redirect URL.
	 * 
	 * Sets the grant and client ID accordingly.
	 * 
	 * @returns {OidcClient | null}
	 * @see OidcProvider#setGrant
	 * @see OidcProvider#setClientId
	 */
	detectDefaultClient() {
		for(let grant of OidcProvider.grants) {
			let defaultClient = this.defaultClients.find(client => Boolean(client.grant_types.includes(grant) && Array.isArray(client.redirect_urls) && client.redirect_urls.find(url => url.startsWith(OidcProvider.redirectUrl))));
			if (defaultClient) {
				this.setGrant(grant);
				this.setClientId(defaultClient.id);
				this.defaultClient = defaultClient;
				return defaultClient;
			}
		}

		return null;
	}

}

/**
 * The global "UI" method to use to open the login URL, either "redirect" (default) or "popup".
 * 
 * @type {string}
 */
OidcProvider.uiMethod = 'redirect';

/**
 * The global redirect URL to use.
 * 
 * By default uses the location of the browser, but removes fragment, query and
 * trailing slash.
 * The fragment conflicts with the fragment appended by the Implicit Flow and
 * the query conflicts with the query appended by the Authorization Code Flow.
 * The trailing slash is removed for consistency.
 * 
 * @type {string}
 */
OidcProvider.redirectUrl = Environment.getUrl().split('#')[0].split('?')[0].replace(/\/$/, '');

/**
 * The supported OpenID Connect grants (flows).
 * 
 * The grants are given as defined in openEO API, e.g. `implicit` and/or `authorization_code+pkce`
 * If not defined there, consult the OpenID Connect Discovery documentation.
 * 
 * Lists the grants by priority so that the first grant is the default grant.
 * The default grant type since client version 2.0.0 is 'authorization_code+pkce'.
 * 
 * @type {Array.<string>}
 */
OidcProvider.grants = [
	'authorization_code+pkce',
	'implicit',
	'client_credentials'
];

module.exports = OidcProvider;