const Utils = require('./utils');
const RequestHandler = require('./RequestHandler');
const PopupController = require('./PopupController');
const IFrameController = require('./IFrameController');
const OpenIdConfigurationResource = require('./OpenIDConfigurationResource')
const TokenValidator = require('./TokenValidator');
const constants = require('./constants');
const AppIDError = require('./errors/AppIDError');
const jsrsasign = require('jsrsasign');
/**
* This class provides functions to support authentication.
*/
class AppID {
/**
* This creates an instance of AppID. Once created, call init() before attempting to sign in.
* @example
* const appID = new AppID();
*/
constructor(
{
popup = new PopupController(),
iframe = new IFrameController(),
openIdConfigResource = new OpenIdConfigurationResource(),
utils,
requestHandler = new RequestHandler(),
tokenValidator = new TokenValidator(),
w = window,
url = URL
} = {}) {
this.popup = popup;
this.iframe = iframe;
this.openIdConfigResource = openIdConfigResource;
this.URL = url;
this.utils = utils;
this.tokenValidator = tokenValidator;
if (!utils) {
this.utils = new Utils({
openIdConfigResource: this.openIdConfigResource,
url: this.URL,
popup: this.popup,
jsrsasign
});
}
this.request = requestHandler.request;
this.window = w;
this.initialized = false;
}
/**
* Initialize AppID. Call this function before attempting to sign in. You must wait for the promise to resolve.
* @param {Object} options
* @param {string} options.clientId - The clientId from the singlepageapp application credentials.
* @param {string} options.discoveryEndpoint - The discoveryEndpoint from the singlepageapp application credentials.
* @param {Object} [options.popup] - The popup configuration.
* @param {Number} options.popup.height - The popup height.
* @param {Number} options.popup.width - The popup width.
* @returns {Promise<void>}
* @throws {AppIDError} For missing required params.
* @throws {RequestError} Any errors during a HTTP request.
* @example
* await appID.init({
* clientId: '<SPA_CLIENT_ID>',
* discoveryEndpoint: '<WELL_KNOWN_ENDPOINT>'
* });
*
*/
async init({clientId, discoveryEndpoint, popup = {height: window.screen.height * .80, width: 400}}) {
if (!clientId) {
throw new AppIDError(constants.MISSING_CLIENT_ID);
}
try {
new this.URL(discoveryEndpoint)
} catch (e) {
throw new AppIDError(constants.INVALID_DISCOVERY_ENDPOINT);
}
await this.openIdConfigResource.init({discoveryEndpoint, requestHandler: this.request});
this.popup.init(popup);
this.clientId = clientId;
this.initialized = true;
}
/**
* @typedef {Object} Tokens
* @property {string} accessToken A JWT.
* @property {Object} accessTokenPayload The decoded JWT.
* @property {string} idToken A JWT.
* @property {Object} idTokenPayload The decoded JWT.
*/
/**
* This will open a sign in widget in a popup which will prompt the user to enter their credentials.
* After a successful sign in, the popup will close and tokens are returned.
* @returns {Promise<Tokens>} The tokens of the authenticated user.
* @throws {PopupError} "Popup closed" - The user closed the popup before authentication was completed.
* @throws {TokenError} Any token validation error.
* @throws {OAuthError} Any errors from the server according to the [OAuth spec]{@link https://tools.ietf.org/html/rfc6749#section-4.1.2.1}. e.g. {error: 'server_error', description: ''}
* @throws {RequestError} Any errors during a HTTP request.
* @example
* const {accessToken, accessTokenPayload, idToken, idTokenPayload} = await appID.signin();
*/
async signin() {
this._validateInitalize();
const endpoint = this.openIdConfigResource.getAuthorizationEndpoint();
let origin = this.window.location.origin;
if (!origin) {
origin = this.window.location.protocol + "//" + this.window.location.hostname + (this.window.location.port ? ':' + this.window.location.port : '');
}
return this.utils.performOAuthFlowAndGetTokens({
origin,
endpoint,
clientId: this.clientId
});
}
/**
* Silent sign in allows you to automatically obtain new tokens for a user without the user having to re-authenticate using a popup.
* This will attempt to authenticate the user in a hidden iframe.
* You will need to [enable Cloud Directory SSO]{@link https://cloud.ibm.com/docs/services/appid?topic=appid-single-page#spa-silent-login}.
* Sign in will be successful only if the user has previously signed in using Cloud Directory and their session is not expired.
* @returns {Promise<Tokens>} The tokens of the authenticated user.
* @throws {OAuthError} Any errors from the server according to the [OAuth spec]{@link https://tools.ietf.org/html/rfc6749#section-4.1.2.1}. e.g. {error: 'access_denied', description: 'User not signed in'}
* @throws {IFrameError} "Silent sign-in timed out" - The iframe will close after 5 seconds if authentication could not be completed.
* @throws {TokenError} Any token validation error.
* @throws {RequestError} Any errors during a HTTP request.
* @example
* const {accessToken, accessTokenPayload, idToken, idTokenPayload} = await appID.silentSignin();
*/
async silentSignin() {
this._validateInitalize();
const endpoint = this.openIdConfigResource.getAuthorizationEndpoint();
const {codeVerifier, nonce, state, url} = this.utils.getAuthParamsAndUrl({
clientId: this.clientId,
origin: this.window.origin,
prompt: constants.PROMPT,
endpoint
});
this.iframe.open(url);
let message;
try {
message = await this.iframe.waitForMessage({messageType: 'authorization_response'});
} finally {
this.iframe.remove();
}
this.utils.verifyMessage({message, state});
let authCode = message.data.code;
return await this.utils.retrieveTokens({
clientId: this.clientId,
authCode,
codeVerifier,
nonce,
openId: this.openIdConfigResource,
windowOrigin: this.window.origin
});
}
/**
* This method will make a GET request to the [user info endpoint]{@link https://us-south.appid.cloud.ibm.com/swagger-ui/#/Authorization%2520Server%2520-%2520Authorization%2520Server%2520V4/oauth-server.userInfo} using the access token of the authenticated user.
* @param {string} accessToken The App ID access token of the user.
* @returns {Promise} The user information for the authenticated user. Example: {sub: '', email: ''}
* @throws {AppIDError} "Access token must be a string" Invalid access token.
* @throws {RequestError} Any errors during a HTTP request.
*/
async getUserInfo(accessToken) {
this._validateInitalize();
if (typeof accessToken !== 'string') {
throw new AppIDError(constants.INVALID_ACCESS_TOKEN);
}
return await this.request(this.openIdConfigResource.getUserInfoEndpoint(), {
headers: {
'Authorization': 'Bearer ' + accessToken
}
});
}
/**
* This method will open a popup to the change password widget for Cloud Directory users.
* You must enable users to manage their account from your app in Cloud Directory settings.
* @param {string} idToken A JWT.
* @returns {Promise<Tokens>} The tokens of the authenticated user.
* @throws {AppIDError} "Expect id token payload object to have identities field"
* @throws {AppIDError} "Must be a Cloud Directory user"
* @throws {AppIDError} "Missing id token string"
* @example
* let tokens = await appID.changePassword(idToken);
*/
async changePassword(idToken) {
this._validateInitalize();
if (!idToken || typeof idToken !== 'string') {
throw new AppIDError(constants.MISSING_ID_TOKEN);
}
let userId;
const publicKeys = await this.openIdConfigResource.getPublicKeys();
let decodedToken = this.tokenValidator.decodeAndValidate({
token: idToken,
publicKeys,
issuer: this.openIdConfigResource.getIssuer(),
clientId: this.clientId
});
if (decodedToken.identities && decodedToken.identities[0] && decodedToken.identities[0].id) {
if (decodedToken.identities[0].provider !== 'cloud_directory') {
throw new AppIDError(constants.NOT_CD_USER);
}
userId = decodedToken.identities[0].id;
} else {
throw new AppIDError(constants.INVALID_ID_TOKEN);
}
const endpoint = this.openIdConfigResource.getIssuer() + constants.CHANGE_PASSWORD;
return await this.utils.performOAuthFlowAndGetTokens({
userId,
origin: this.window.origin,
clientId: this.clientId,
endpoint
});
}
/**
* This method will open a popup to the change details widget for Cloud Directory users.
* You must enable users to manage their account from your app in Cloud Directory settings.
* @param {Object} tokens App ID tokens
* @returns {Promise<Tokens>}
* @throws {AppIDError} "Missing id token string"
* @throws {AppIDError} "Missing access token string"
* @throws {AppIDError} "Missing tokens object"
* @example
* let tokens = {accessToken, idToken}
* let newTokens = await appID.changeDetails(tokens);
*/
async changeDetails({accessToken, idToken}) {
this._validateInitalize();
if (!accessToken && typeof accessToken !== 'string') {
throw new AppIDError(constants.MISSING_ACCESS_TOKEN);
}
if (!idToken && typeof idToken !== 'string') {
throw new AppIDError(constants.MISSING_ID_TOKEN);
}
const generateCodeUrl = this.openIdConfigResource.getIssuer() + constants.GENERATE_CODE;
const changeDetailsCode = await this.request(generateCodeUrl, {
headers: {
'Authorization': 'Bearer ' + accessToken + ' ' + idToken
}
});
const endpoint = this.openIdConfigResource.getIssuer() + constants.CHANGE_DETAILS;
return this.utils.performOAuthFlowAndGetTokens({
origin: this.window.origin,
clientId: this.clientId,
endpoint,
changeDetailsCode
});
}
/**
*
* @private
*/
_validateInitalize() {
if (!this.initialized) {
throw new AppIDError(constants.FAIL_TO_INITIALIZE);
}
}
}
module.exports = AppID;