/* Copyright 2024 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
* See LICENSE.md in the project root.
*/
'use strict';
import { assert, isStr, isNonEmptyString, isObject, isUrl, isStrIn } from './validate.js';
import { cloneDeep } from './object.js';
import { urlMapper } from './url.js';
import { ENDPOINTS, NAMESPACE } from './config.js';
import EventEmitter from 'tiny-emitter';
import Cache from './cache.js';
import * as popup from './popup.js';
import RESTClient from './RESTClient.js';
import SDKError from './SDKError.js';
import * as spidTalk from './spidTalk.js';
import version from './version.js';
/**
* @typedef {object} LoginOptions
* @property {string} state - An opaque value used by the client to maintain state between
* the request and callback. It's also recommended to prevent CSRF {@link https://tools.ietf.org/html/rfc6749#section-10.12}
* @property {string} [acrValues] - Authentication Context Class Reference Values. If
* omitted, the user will be asked to authenticate using username+password.
* For 2FA (Two-Factor Authentication) possible values are `sms`, `otp` (one time password),
* `password` (will force password confirmation, even if user is already logged in), `eid`. Those values might
* be mixed as space-separated string. To make sure that user has authenticated with 2FA you need
* to verify AMR (Authentication Methods References) claim in ID token.
* Might also be used to ensure additional acr (sms, otp) for already logged in users.
* Supported value is also 'otp-email' means one time password using email.
* @property {string} [scope] - The OAuth scopes for the tokens. This is a list of
* scopes, separated by space. If the list of scopes contains `openid`, the generated tokens
* includes the id token which can be useful for getting information about the user. Omitting
* scope is allowed, while `invalid_scope` is returned when the client asks for a scope you
* aren’t allowed to request. {@link https://tools.ietf.org/html/rfc6749#section-3.3}
* @property {string} [redirectUri] - Redirect uri that will receive the
* code. Must exactly match a redirectUri from your client in self-service
* @property {boolean} [preferPopup] - Should we try to open a popup window?
* @property {string} [loginHint] - user email or UUID hint
* @property {string} [tag] - Pulse tag
* @property {string} [teaser] - Teaser slug. Teaser with given slug will be displayed
* in place of default teaser
* @property {number|string} [maxAge] - Specifies the allowable elapsed time in seconds since
* the last time the End-User was actively authenticated. If last authentication time is more
* than maxAge seconds in the past, re-authentication will be required. See the OpenID Connect
* spec section 3.1.2.1 for more information
* @property {string} [locale] - Optional parameter to overwrite client locale setting.
* New flows supports nb_NO, fi_FI, sv_SE, en_US
* @property {boolean} [oneStepLogin] - display username and password on one screen
* @property {string} [prompt] - String that specifies whether the Authorization Server prompts the
* End-User for reauthentication or confirm account screen. Supported values: `select_account` or `login`
*/
/**
* @typedef {object} SimplifiedLoginWidgetLoginOptions
* @property {string|function(): (string|Promise<string>)} state - An opaque value used by the client to maintain state between
* the request and callback. It's also recommended to prevent CSRF {@link https://tools.ietf.org/html/rfc6749#section-10.12}
* @property {string} [acrValues] - Authentication Context Class Reference Values. If
* omitted, the user will be asked to authenticate using username+password.
* For 2FA (Two-Factor Authentication) possible values are `sms`, `otp` (one time password) and
* `password` (will force password confirmation, even if user is already logged in). Those values might
* be mixed as space-separated string. To make sure that user has authenticated with 2FA you need
* to verify AMR (Authentication Methods References) claim in ID token.
* Might also be used to ensure additional acr (sms, otp) for already logged in users.
* Supported value is also 'otp-email' means one time password using email.
* @property {string} [scope] - The OAuth scopes for the tokens. This is a list of
* scopes, separated by space. If the list of scopes contains `openid`, the generated tokens
* includes the id token which can be useful for getting information about the user. Omitting
* scope is allowed, while `invalid_scope` is returned when the client asks for a scope you
* aren’t allowed to request. {@link https://tools.ietf.org/html/rfc6749#section-3.3}
* @property {string} [redirectUri] - Redirect uri that will receive the
* code. Must exactly match a redirectUri from your client in self-service
* @property {boolean} [preferPopup] - Should we try to open a popup window?
* @property {string} [loginHint] - user email or UUID hint
* @property {string} [tag] - Pulse tag
* @property {string} [teaser] - Teaser slug. Teaser with given slug will be displayed
* in place of default teaser
* @property {number|string} [maxAge] - Specifies the allowable elapsed time in seconds since
* the last time the End-User was actively authenticated. If last authentication time is more
* than maxAge seconds in the past, re-authentication will be required. See the OpenID Connect
* spec section 3.1.2.1 for more information
* @property {string} [locale] - Optional parameter to overwrite client locale setting.
* New flows supports nb_NO, fi_FI, sv_SE, en_US
* @property {boolean} [oneStepLogin] - display username and password on one screen
* @property {string} [prompt] - String that specifies whether the Authorization Server prompts the
* End-User for reauthentication or confirm account screen. Supported values: `select_account` or `login`
*/
/**
* @typedef {object} HasSessionSuccessResponse
* @property {boolean} result - Is the user connected to the merchant? (it means that the merchant
* id is in the list of merchants listed of this user in the database)? Example: false
* @property {string} userStatus - Example: 'notConnected' or 'connected'. Deprecated, use
* `Identity.isConnected()`
* @property {string} baseDomain - Example: 'localhost'
* @property {string} id - Example: '58eca10fdbb9f6df72c3368f'. Obsolete
* @property {number} userId - Example: 37162
* @property {string} uuid - Example: 'b3b23aa7-34f2-5d02-a10e-5a3455c6ab2c'
* @property {string} sp_id - Example: 'eyJjbGllbnRfaWQ...'
* @property {number} expiresIn - Example: 30 * 60 * 1000 (for 30 minutes)
* @property {number} serverTime - Example: 1506285759
* @property {string} sig - Example: 'NCdzXaz4ZRb7...' The sig parameter is a concatenation of an
* HMAC SHA-256 signature string, a dot (.) and a base64url encoded JSON object (session).
* {@link http://techdocs.spid.no/sdks/js/response-signature-and-validation/}
* @property {string} displayName - (Only for connected users) Example: 'batman'
* @property {string} givenName - (Only for connected users) Example: 'Bruce'
* @property {string} familyName - (Only for connected users) Example: 'Wayne'
* @property {string} gender - (Only for connected users) Example: 'male', 'female', 'undisclosed'
* @property {string} photo - (Only for connected users) Example:
* 'http://www.srv.com/some/picture.jpg'
* @property {boolean} tracking - (Only for connected users)
* @property {boolean} clientAgreementAccepted - (Only for connected users)
* @property {boolean} defaultAgreementAccepted - (Only for connected users)
* @property {string} pairId
* @property {string} sdrn
*/
/**
* Emitted when an error happens (useful for debugging)
* @event Identity#error
*/
/**
* @typedef {object} HasSessionFailureResponse
* @property {object} error
* @property {number} error.code - Typically an HTTP response code. Example: 401
* @property {string} error.description - Example: "No session found!"
* @property {string} error.type - Example: "UserException"
* @property {object} response
* @property {string} response.baseDomain - Example: "localhost"
* @property {number} response.expiresIn - Time span in milliseconds. Example: 30 * 60 * 1000 (for 30 minutes)
* @property {boolean} response.result
* @property {number} response.serverTime - Server time in seconds since the Unix Epoch. Example: 1506287788
*/
/**
* @typedef {object} SimplifiedLoginData
* @property {string} identifier - Deprecated: User UUID, to be be used as `loginHint` for {@link Identity#login}
* @property {string} display_text - Human-readable user identifier
* @property {string} client_name - Client name
*/
/**
* @typedef {object} SimplifiedLoginWidgetOptions
* @property {string} encoding - expected encoding of simplified login widget. Could be utf-8 (default), iso-8859-1 or iso-8859-15
*/
const HAS_SESSION_CACHE_KEY = 'hasSession-cache';
const SESSION_CALL_BLOCKED_CACHE_KEY = 'sessionCallBlocked-cache';
const SESSION_CALL_BLOCKED_TTL = 1000 * 60 * 5;
const TAB_ID_KEY = 'tab-id-cache';
const TAB_ID = Math.floor(Math.random() * 100000)
const TAB_ID_TTL = 1000 * 60 * 60 * 24 * 30;
const globalWindow = () => window;
/**
* Provides Identity functionalty to a web page
*/
export class Identity extends EventEmitter {
/**
* @param {object} options
* @param {string} options.clientId - Example: "1234567890abcdef12345678"
* @param {string} options.sessionDomain - Example: "https://id.site.com"
* @param {string} options.redirectUri - Example: "https://site.com"
* @param {string} [options.env=PRE] - Schibsted account environment: `PRE`, `PRO`, `PRO_NO`, `PRO_FI` or `PRO_DK`
* @param {function} [options.log] - A function that receives debug log information. If not set,
* no logging will be done
* @param {object} [options.window] - window object
* @param {function} [options.callbackBeforeRedirect] - callback triggered before session refresh redirect happen
* @throws {SDKError} - If any of options are invalid
*/
constructor({
clientId,
redirectUri,
sessionDomain,
env = 'PRE',
log,
window = globalWindow(),
callbackBeforeRedirect = ()=>{}
}) {
super();
assert(isNonEmptyString(clientId), 'clientId parameter is required');
assert(isObject(window), 'The reference to window is missing');
assert(!redirectUri || isUrl(redirectUri), 'redirectUri parameter is invalid');
assert(sessionDomain && isUrl(sessionDomain), 'sessionDomain parameter is not a valid URL');
spidTalk.emulate(window);
this._sessionInitiatedSent = false;
this.window = window;
this.clientId = clientId;
this.sessionStorageCache = new Cache(() => this.window && this.window.sessionStorage);
this.localStorageCache = new Cache(() => this.window && this.window.localStorage);
this.redirectUri = redirectUri;
this.env = env;
this.log = log;
this.callbackBeforeRedirect = callbackBeforeRedirect;
this._sessionDomain = sessionDomain;
// Internal hack: set to false to always refresh from hassession
this._enableSessionCaching = true;
// Old session
this._session = {};
this._setSessionServiceUrl(sessionDomain);
this._setSpidServerUrl(env);
this._setBffServerUrl(env);
this._setOauthServerUrl(env);
this._setGlobalSessionServiceUrl(env);
this._unblockSessionCall();
}
/**
* Read tabId from session storage
* @returns {number}
* @private
*/
_getTabId() {
if (this._enableSessionCaching) {
const tabId = this.sessionStorageCache.get(TAB_ID_KEY);
if (!tabId) {
this.sessionStorageCache.set(TAB_ID_KEY, TAB_ID, TAB_ID_TTL);
return TAB_ID;
}
return tabId;
}
}
/**
* Checks if getting session is blocked
* @private
*
* @returns {boolean|void}
*/
_isSessionCallBlocked(){
return this.localStorageCache.get(SESSION_CALL_BLOCKED_CACHE_KEY);
}
/**
* Block calls to get session
* @private
*
* @returns {void}
*/
_blockSessionCall(){
const SESSION_CALL_BLOCKED = true;
this.localStorageCache.set(
SESSION_CALL_BLOCKED_CACHE_KEY,
SESSION_CALL_BLOCKED,
SESSION_CALL_BLOCKED_TTL
);
}
/**
* Unblocks calls to get session
* @private
*
* @returns {void}
*/
_unblockSessionCall(){
this.localStorageCache.delete(SESSION_CALL_BLOCKED_CACHE_KEY);
}
/**
* Set SPiD server URL
* @private
* @param {string} url - real URL or 'PRE' style key
* @returns {void}
*/
_setSpidServerUrl(url) {
assert(isStr(url), `url parameter is invalid: ${url}`);
this._spid = new RESTClient({
serverUrl: urlMapper(url, ENDPOINTS.SPiD),
log: this.log,
defaultParams: { client_id: this.clientId, redirect_uri: this.redirectUri },
});
}
/**
* Set OAuth server URL
* @private
* @param {string} url - real URL or 'PRE' style key
* @returns {void}
*/
_setOauthServerUrl(url) {
assert(isStr(url), `url parameter is invalid: ${url}`);
this._oauthService = new RESTClient({
serverUrl: urlMapper(url, ENDPOINTS.SPiD),
log: this.log,
defaultParams: { client_id: this.clientId, redirect_uri: this.redirectUri },
});
}
/**
* Set BFF server URL
* @private
* @param {string} url - real URL or 'PRE' style key
* @returns {void}
*/
_setBffServerUrl(url) {
assert(isStr(url), `url parameter is invalid: ${url}`);
this._bffService = new RESTClient({
serverUrl: urlMapper(url, ENDPOINTS.BFF),
log: this.log,
defaultParams: { client_id: this.clientId, redirect_uri: this.redirectUri },
});
}
/**
* Set site-specific session-service domain
* @private
* @param {string} domain - real URL — (**not** 'PRE' style env key)
* @returns {void}
*/
_setSessionServiceUrl(domain) {
assert(isStr(domain), `domain parameter is invalid: ${domain}`);
const client_sdrn = `sdrn:${NAMESPACE[this.env]}:client:${this.clientId}`;
this._sessionService = new RESTClient({
serverUrl: domain,
log: this.log,
defaultParams: { client_sdrn, redirect_uri: this.redirectUri, sdk_version: version },
});
}
/**
* Set global session-service server URL
* @private
* @param {string} url - real URL or 'PRE' style key
* @returns {void}
*/
_setGlobalSessionServiceUrl(url) {
assert(isStr(url), `url parameter is invalid: ${url}`);
const client_sdrn = `sdrn:${NAMESPACE[this.env]}:client:${this.clientId}`;
this._globalSessionService = new RESTClient({
serverUrl: urlMapper(url, ENDPOINTS.SESSION_SERVICE),
log: this.log,
defaultParams: { client_sdrn, sdk_version: version },
});
}
/**
* Emits the relevant events based on the previous and new reply from hassession
* @private
* @param {object} previous
* @param {object} current
* @returns {void}
*/
_emitSessionEvent(previous, current) {
/**
* Emitted when the user is logged in (This happens as a result of calling
* {@link Identity#hasSession}, so it is also emitted if the user was previously logged in)
* @event Identity#login
*/
if (current.userId) {
this.emit('login', current);
}
/**
* Emitted when the user logged out
* @event Identity#logout
*/
if (previous.userId && !current.userId) {
this.emit('logout', current);
}
/**
* Emitted when the user is changed. This happens as a result of calling
* {@link Identity#hasSession}, and is emitted if there was a user both before and after
* this invocation, and the userId has now changed
* @event Identity#userChange
*/
if (previous.userId && current.userId && previous.userId !== current.userId) {
this.emit('userChange', current);
}
if (previous.userId || current.userId) {
/**
* Emitted when the session is changed. More accurately, this event is emitted if there
* was a logged-in user either before or after {@link Identity#hasSession} was called.
* In practice, this means the event is emitted a lot
* @event Identity#sessionChange
*/
this.emit('sessionChange', current);
} else {
/**
* Emitted when there is no logged-in user. More specifically, it means that there was
* no logged-in user neither before nor after {@link Identity#hasSession} was called
* @event Identity#notLoggedin
*/
this.emit('notLoggedin', current);
}
/**
* Emitted when the session is first created
* @event Identity#sessionInit
*/
if (current.userId && !this._sessionInitiatedSent) {
this._sessionInitiatedSent = true;
this.emit('sessionInit', current);
}
/**
* Emitted when the user status changes. This happens as a result of calling
* {@link Identity#hasSession}
* @event Identity#statusChange
*/
if (previous.userStatus !== current.userStatus) {
this.emit('statusChange', current);
}
}
/**
* Close this.popup if it exists and is open
* @private
* @returns {void}
*/
_closePopup() {
if (this.popup) {
if (!this.popup.closed) {
this.popup.close();
}
this.popup = null;
}
}
/**
* Set the Varnish cookie (`SP_ID`) when hasSession() is called. Note that most browsers require
* that you are on a "real domain" for this to work — so, **not** `localhost`
* @param {object} [options]
* @param {number} [options.expiresIn] Override this to set number of seconds before the varnish
* cookie expires. The default is to use the same time that hasSession responses are cached for
* @param {string} [options.domain] Override cookie domain. E.g. «vg.no» instead of «www.vg.no»
* @returns {void}
*/
enableVarnishCookie(options) {
let expiresIn = 0;
let domain;
if (Number.isInteger(options)) {
expiresIn = options;
}
else if (typeof options == 'object') {
expiresIn = options.expiresIn || expiresIn;
domain = options.domain || domain;
}
assert(Number.isInteger(expiresIn), `'expiresIn' must be an integer`);
assert(expiresIn >= 0, `'expiresIn' cannot be negative`);
this.setVarnishCookie = true;
this.varnishExpiresIn = expiresIn;
this.varnishCookieDomain = domain;
}
/**
* Set the Varnish cookie if configured
* @private
* @param {HasSessionSuccessResponse} sessionData
* @returns {void}
*/
_maybeSetVarnishCookie(sessionData) {
if (!this.setVarnishCookie) {
return;
}
const date = new Date();
const validExpires = this.varnishExpiresIn
|| typeof sessionData.expiresIn === 'number' && sessionData.expiresIn > 0;
if (validExpires) {
const expires = this.varnishExpiresIn || sessionData.expiresIn;
date.setTime(date.getTime() + (expires * 1000));
} else {
date.setTime(0);
}
// If the domain is missing or of the wrong type, we'll use document.domain
let domain = this.varnishCookieDomain ||
(typeof sessionData.baseDomain === 'string'
? sessionData.baseDomain
: document.domain) ||
'';
const cookie = [
`SP_ID=${sessionData.sp_id}`,
`expires=${date.toUTCString()}`,
`path=/`,
`domain=.${domain}`
].join('; ');
document.cookie = cookie;
}
/**
* Clear the Varnish cookie if configured
* @private
* @returns {void}
*/
_maybeClearVarnishCookie() {
if (this.setVarnishCookie) {
this._clearVarnishCookie();
}
}
/**
* Clear the Varnish cookie
* @private
* @returns {void}
*/
_clearVarnishCookie() {
const baseDomain = this._session && typeof this._session.baseDomain === 'string'
? this._session.baseDomain
: document.domain;
let domain = this.varnishCookieDomain ||
baseDomain ||
'';
document.cookie = `SP_ID=nothing; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
}
/**
* Log used settings and version
* @throws {SDKError} - If log method is not provided
* @return {void}
*/
logSettings() {
if (!this.log && !window.console) {
throw new SDKError('You have to provide log method in constructor');
}
const log = this.log || console.log;
const settings = {
clientId: this.clientId,
redirectUri: this.redirectUri,
env: this.env,
sessionDomain: this._sessionDomain,
sdkVersion: version
}
log(`Schibsted account SDK for browsers settings: \n${JSON.stringify(settings, null, 2)}`);
}
/**
* @summary Queries the hassession endpoint and returns information about the status of the user
* @description When we send a request to this endpoint, cookies sent along with the request
* determines the status of the user.
* @throws {SDKError} - If the call to the hasSession service fails in any way (this will happen
* if, say, the user is not logged in)
* @fires Identity#login
* @fires Identity#logout
* @fires Identity#userChange
* @fires Identity#sessionChange
* @fires Identity#notLoggedin
* @fires Identity#sessionInit
* @fires Identity#statusChange
* @fires Identity#error
* @return {Promise<HasSessionSuccessResponse|HasSessionFailureResponse>}
*/
hasSession() {
const isSessionCallBlocked = this._isSessionCallBlocked()
if (isSessionCallBlocked) {
return this._session;
}
if (this._hasSessionInProgress) {
return this._hasSessionInProgress;
}
const _postProcess = (sessionData) => {
if (sessionData.error) {
throw new SDKError('HasSession failed', sessionData.error);
}
this._maybeSetVarnishCookie(sessionData);
this._emitSessionEvent(this._session, sessionData);
this._session = sessionData;
return sessionData;
};
const _checkRedirectionNeed = (sessionData={})=>{
const sessionDataKeys = Object.keys(sessionData);
return sessionDataKeys.length === 1 &&
sessionDataKeys[0] === 'redirectURL';
}
const _getSession = async () => {
if (this._enableSessionCaching) {
// Try to resolve from cache (it has a TTL)
let cachedSession = this.sessionStorageCache.get(HAS_SESSION_CACHE_KEY);
if (cachedSession) {
return _postProcess(cachedSession);
}
}
let sessionData = null;
try {
sessionData = await this._sessionService.get('/v2/session', {tabId: this._getTabId()});
} catch (err) {
if (err && err.code === 400 && this._enableSessionCaching) {
const expiresIn = 1000 * (err.expiresIn || 300);
this.sessionStorageCache.set(HAS_SESSION_CACHE_KEY, { error: err }, expiresIn);
}
throw err;
}
if (sessionData){
// for expiring session and safari browser do full page redirect to gain new session
if(_checkRedirectionNeed(sessionData)){
this._blockSessionCall();
await this.callbackBeforeRedirect();
return this._sessionService.makeUrl(sessionData.redirectURL, {tabId: this._getTabId()});
}
if (this._enableSessionCaching) {
const expiresIn = 1000 * (sessionData.expiresIn || 300);
this.sessionStorageCache.set(HAS_SESSION_CACHE_KEY, sessionData, expiresIn);
}
}
return _postProcess(sessionData);
};
this._hasSessionInProgress = _getSession()
.then(
sessionData => {
this._hasSessionInProgress = false;
if (isUrl(sessionData)) {
return this.window.location.href = sessionData;
}
return sessionData;
},
err => {
this.emit('error', err);
this._hasSessionInProgress = false;
throw new SDKError('HasSession failed', err);
}
);
return this._hasSessionInProgress;
}
/**
* @async
* @summary Allows the client app to check if the user is logged in to Schibsted account
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @return {Promise<boolean>}
*/
async isLoggedIn() {
try {
const data = await this.hasSession();
return 'result' in data;
} catch (_) {
return false;
}
}
/**
* Removes the cached user session.
* @returns {void}
*/
clearCachedUserSession() {
this.sessionStorageCache.delete(HAS_SESSION_CACHE_KEY);
}
/**
* @async
* @summary Allows the caller to check if the current user is connected to the client_id in
* Schibsted account. Being connected means that the user has agreed for their account to be
* used by your web app and have accepted the required terms
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @summary Check if the user is connected to the client_id
* @return {Promise<boolean>}
*/
async isConnected() {
try {
const data = await this.hasSession();
// if data is not an object, the promise will fail.
// if the result is present, it's boolean. But if it's not, it should be assumed false.
return !!data.result;
} catch (_) {
return false;
}
}
/**
* @async
* @summary Returns information about the user
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @throws {SDKError} If the user isn't connected to the merchant
* @throws {SDKError} If we couldn't get the user
* @return {Promise<HasSessionSuccessResponse>}
*/
async getUser() {
const user = await this.hasSession();
if (!user.result) {
throw new SDKError('The user is not connected to this merchant');
}
return cloneDeep(user);
}
/**
* @async
* @summary
* In Schibsted account, there are multiple ways of identifying a user; the `userId`,
* `uuid` and `externalId` used for identifying a user-merchant pair (see {@link Identity#getExternalId}).
* There are reasons for them all to exist. The `userId` is a numeric identifier, but
* since Schibsted account is deployed separately in Norway and Sweden, there are a lot of
* duplicates. The `userId` was introduced early, so many sites still need to use them for
* legacy reasons. The `uuid` is universally unique, and so — if we could disregard a lot of
* Schibsted components depending on the numeric `userId` — it would be a good identifier to use
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @throws {SDKError} If the user isn't connected to the merchant
* @return {Promise<string>} The `userId` field (not to be confused with the `uuid`)
*/
async getUserId() {
const user = await this.hasSession();
if (user.userId && user.result) {
return user.userId;
}
throw new SDKError('The user is not connected to this merchant');
}
/**
* @async
* @function
* @summary
* Retrieves the external identifier (`externalId`) for the authenticated user.
*
* In Schibsted Account there are multiple ways of identifying users, however for integrations with
* third-parties it's recommended to use `externalId` as it does not disclose
* any critical data whilst allowing for user identification.
*
* `externalId` is merchant-scoped using a pairwise identifier (`pairId`),
* meaning the same user's ID will differ between merchants.
* Additionally, this identifier is bound to the external party provided as argument.
*
* @param {string} externalParty
* @param {string|null} optionalSuffix
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @throws {SDKError} If the `pairId` is missing in user session.
* @throws {SDKError} If the `externalParty` is not defined
* @return {Promise<string>} The merchant- and 3rd-party-specific `externalId`
*/
async getExternalId(externalParty, optionalSuffix = "") {
const { pairId } = await this.hasSession();
if (!pairId)
throw new SDKError('pairId missing in user session!');
if(!externalParty || externalParty.length === 0) {
throw new SDKError('externalParty cannot be empty');
}
const _toHexDigest = (hashBuffer) =>{
// convert buffer to byte array
const hashArray = Array.from(new Uint8Array(hashBuffer));
// convert bytes to hex string
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
const _getSha256Digest = (data) => {
return crypto.subtle.digest('SHA-256', data);
}
const _hashMessage = async (message) => {
const msgUint8 = new TextEncoder().encode(message);
return _getSha256Digest(msgUint8).then( (it) => _toHexDigest(it));
}
const _constructMessage = (pairId, externalParty, optionalSuffix) => {
return optionalSuffix
? `${pairId}:${externalParty}:${optionalSuffix}`
: `${pairId}:${externalParty}`;
}
return _hashMessage(_constructMessage(pairId, externalParty, optionalSuffix))
}
/**
* @async
* @summary Enables brands to programmatically get the current the SDRN based on the user's session.
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @throws {SDKError} If the SDRN is missing in user session object.
* @returns {Promise<string>}
*/
async getUserSDRN() {
const { sdrn } = await this.hasSession();
if (sdrn) {
return sdrn;
}
throw new SDKError('Failed to get SDRN from user session');
}
/**
* @async
* @summary In Schibsted account, there are two ways of identifying a user; the `userId` and the
* `uuid`. There are reasons for them both existing. The `userId` is a numeric identifier, but
* since Schibsted account is deployed separately in Norway and Sweden, there are a lot of
* duplicates. The `userId` was introduced early, so many sites still need to use them for
* legacy reasons. The `uuid` is universally unique, and so — if we could disregard a lot of
* Schibsted components depending on the numeric `userId` — it would be a good identifier to use
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @throws {SDKError} If the user isn't connected to the merchant
* @return {Promise<string>} The `uuid` field (not to be confused with the `userId`)
*/
async getUserUuid() {
const user = await this.hasSession();
if (user.uuid && user.result) {
return user.uuid;
}
throw new SDKError('The user is not connected to this merchant');
}
/**
* @async
* @summary Get basic information about any user currently logged-in to their Schibsted account
* in this browser. Can be used to provide context in a continue-as prompt.
* @description This function relies on the global Schibsted account user session cookie, which
* is a third-party cookie and hence might be blocked by the browser (for example due to ITP in
* Safari). So there's no guarantee any data is returned, even though a user is logged-in in
* the current browser.
* @return {Promise<SimplifiedLoginData|null>}
*/
async getUserContextData() {
try {
return await this._globalSessionService.get('/user-context');
} catch (_) {
return null;
}
}
/**
* If a popup is desired, this function needs to be called in response to a user event (like
* click or tap) in order to work correctly. Otherwise the popup will be blocked by the
* browser's popup blockers and has to be explicitly authorized to be shown.
* @summary Perform a login, either using a full-page redirect or a popup
* @see https://tools.ietf.org/html/rfc6749#section-4.1.1
*
* @param {LoginOptions} options
* @param {string} options.state
* @param {string} [options.acrValues]
* @param {string} [options.scope=openid]
* @param {string} [options.redirectUri]
* @param {boolean} [options.preferPopup=false]
* @param {string} [options.loginHint]
* @param {string} [options.tag]
* @param {string} [options.teaser]
* @param {number|string} [options.maxAge]
* @param {string} [options.locale]
* @param {boolean} [options.oneStepLogin=false]
* @param {string} [options.prompt=select_account]
* @return {Window|null} - Reference to popup window if created (or `null` otherwise)
*/
login({
state,
acrValues = '',
scope = 'openid',
redirectUri = this.redirectUri,
preferPopup = false,
loginHint = '',
tag = '',
teaser = '',
maxAge = '',
locale = '',
oneStepLogin = false,
prompt = 'select_account'
}) {
this._closePopup();
this.sessionStorageCache.delete(HAS_SESSION_CACHE_KEY);
const url = this.loginUrl({
state,
acrValues,
scope,
redirectUri,
loginHint,
tag,
teaser,
maxAge,
locale,
oneStepLogin,
prompt
});
if (preferPopup) {
this.popup =
popup.open(this.window, url, 'Schibsted account', { width: 360, height: 570 });
if (this.popup) {
return this.popup;
}
}
this.window.location.href = url;
return null;
}
/**
* @async
* @summary Retrieve the sp_id (Varnish ID)
* @description This function calls {@link Identity#hasSession} internally and thus has the side
* effect that it might perform an auto-login on the user
* @return {Promise<string|null>} - The sp_id string or null (if the server didn't return it)
*/
async getSpId() {
try {
const user = await this.hasSession();
return user.sp_id || null;
} catch (_) {
return null;
}
}
/**
* @summary Logs the user out from the Identity platform
* @param {string} redirectUri - Where to redirect the browser after logging out of Schibsted
* account
* @return {void}
*/
logout(redirectUri = this.redirectUri) {
this.sessionStorageCache.delete(HAS_SESSION_CACHE_KEY);
this._maybeClearVarnishCookie();
this.emit('logout');
this.window.location.href = this.logoutUrl(redirectUri);
}
/**
* Generates the link to the new login page that'll be used in the popup or redirect flow
* @param {LoginOptions} options
* @param {string} options.state
* @param {string} [options.acrValues]
* @param {string} [options.scope=openid]
* @param {string} [options.redirectUri]
* @param {string} [options.loginHint]
* @param {string} [options.tag]
* @param {string} [options.teaser]
* @param {number|string} [options.maxAge]
* @param {string} [options.locale]
* @param {boolean} [options.oneStepLogin=false]
* @param {string} [options.prompt=select_account]
* @return {string} - The url
*/
loginUrl({
state,
acrValues = '',
scope = 'openid',
redirectUri = this.redirectUri,
loginHint = '',
tag = '',
teaser = '',
maxAge = '',
locale = '',
oneStepLogin = false,
prompt = 'select_account',
}) {
if (typeof arguments[0] !== 'object') {
// backward compatibility
state = arguments[0];
acrValues = arguments[1];
scope = arguments[2] || scope;
redirectUri = arguments[3] || redirectUri;
loginHint = arguments[4] || loginHint;
tag = arguments[5] || tag;
teaser = arguments[6] || teaser;
maxAge = isNaN(arguments[7]) ? maxAge : arguments[7];
}
const isValidAcrValue = (acrValue) => isStrIn(acrValue, ['password', 'otp', 'sms', 'eid-dk', 'eid-no', 'eid-se', 'eid-fi', 'eid'], true);
assert(!acrValues || isStrIn(acrValues, ['', 'otp-email'], true) || acrValues.split(' ').every(isValidAcrValue),
`The acrValues parameter is not acceptable: ${acrValues}`);
assert(isUrl(redirectUri),
`loginUrl(): redirectUri must be a valid url but is ${redirectUri}`);
assert(isNonEmptyString(state),
`the state parameter should be a non empty string but it is ${state}`);
return this._oauthService.makeUrl('oauth/authorize', {
response_type: 'code',
redirect_uri: redirectUri,
scope,
state,
acr_values: acrValues,
login_hint: loginHint,
tag,
teaser,
max_age: maxAge,
locale,
one_step_login: oneStepLogin || '',
prompt: acrValues ? '' : prompt
});
}
/**
* The url for logging the user out
* @param {string} [redirectUri=this.redirectUri]
* @return {string} url
*/
logoutUrl(redirectUri = this.redirectUri) {
assert(isUrl(redirectUri), `logoutUrl(): redirectUri is invalid`);
const params = { redirect_uri: redirectUri };
return this._sessionService.makeUrl('logout', params);
}
/**
* The account summary page url
* @param {string} [redirectUri=this.redirectUri]
* @return {string}
*/
accountUrl(redirectUri = this.redirectUri) {
return this._spid.makeUrl('profile-pages', {
response_type: 'code',
redirect_uri: redirectUri
});
}
/**
* The phone editing page url
* @param {string} [redirectUri=this.redirectUri]
* @return {string}
*/
phonesUrl(redirectUri = this.redirectUri) {
return this._spid.makeUrl('profile-pages/about-you/phone', {
response_type: 'code',
redirect_uri: redirectUri
});
}
/**
* Function responsible for loading and displaying simplified login widget. How often
* widget will be display is up to you. Preferred way would be to show it once per user,
* and store that info in localStorage. Widget will be display only if user is logged in to SSO.
*
* @async
* @param {SimplifiedLoginWidgetLoginOptions} loginParams - the same as `options` param for login function. Login will be called on user
* continue action. `state` might be string or async function.
* @param {SimplifiedLoginWidgetOptions} [options] - additional configuration of Simplified Login Widget
* @fires Identity#simplifiedLoginOpened
* @fires Identity#simplifiedLoginCancelled
* @return {Promise<boolean|SDKError>} - will resolve to true if widget will be display. Otherwise, will throw SDKError
*/
async showSimplifiedLoginWidget(loginParams, options) {
// getUserContextData doesn't throw exception
const userData = await this.getUserContextData();
const queryParams = { client_id: this.clientId };
if (options && options.encoding) {
queryParams.encoding = options.encoding;
}
const widgetUrl = this._bffService.makeUrl('simplified-login-widget', queryParams, false);
const prepareLoginParams = async (loginPrams) => {
if (typeof loginPrams.state === 'function') {
loginPrams.state = await loginPrams.state();
}
return loginPrams;
}
return new Promise(
(resolve, reject) => {
if (!userData || !userData.display_text || !userData.identifier) {
return reject(new SDKError('Missing user data'));
}
const initialParams = {
displayText: userData.display_text,
env: this.env,
clientName: userData.client_name,
clientId: this.clientId,
providerId: userData.provider_id,
windowWidth: () => window.innerWidth,
windowOnResize: (f) => {
window.onresize = f;
},
};
if (options && options.locale) {
initialParams.locale = options.locale;
}
const loginHandler = async () => {
this.login(Object.assign(await prepareLoginParams(loginParams), {loginHint: userData.identifier}));
};
const loginNotYouHandler = async () => {
this.login(Object.assign(await prepareLoginParams(loginParams), {loginHint: userData.identifier, prompt: 'login'}));
};
const initHandler = () => {
/**
* Emitted when the simplified login widget is displayed on the screen
* @event Identity#simplifiedLoginOpened
*/
this.emit('simplifiedLoginOpened');
}
const cancelLoginHandler = () => {
/**
* Emitted when the user closes the simplified login widget
* @event Identity#simplifiedLoginCancelled
*/
this.emit('simplifiedLoginCancelled');
}
if (window.openSimplifiedLoginWidget) {
window.openSimplifiedLoginWidget(initialParams, loginHandler, loginNotYouHandler, initHandler, cancelLoginHandler);
return resolve(true);
}
const simplifiedLoginWidget = document.createElement("script");
simplifiedLoginWidget.type = "text/javascript";
simplifiedLoginWidget.src = widgetUrl;
simplifiedLoginWidget.onload = () => {
window.openSimplifiedLoginWidget(initialParams, loginHandler, loginNotYouHandler, initHandler, cancelLoginHandler);
resolve(true);
};
simplifiedLoginWidget.onerror = () => {
reject(new SDKError('Error when loading simplified login widget content'));
};
document.getElementsByTagName('body')[0].appendChild(simplifiedLoginWidget);
});
}
}
export default Identity;