/// declare class Buffer { constructor(str:string, encoding?:string); toString(encoding:string):string; } import context = require('./Context'); import utils = require('./Utils'); import observable = require('./Observable'); import cache = require('./Cache'); import log = require('./Log'); import request = require('./http/Request'); import r = require('./http/Response'); export class Platform extends observable.Observable { public server:string; public apiKey:string; public account:string; public urlPrefix:string; public apiVersion:string; public accountPrefix:string; public accessTokenTtl:number; public refreshTokenTtl:number; public refreshTokenTtlRemember:number; public refreshHandicapMs:number; public refreshDelayMs:number; public clearCacheOnRefreshError:boolean; public refreshPromise:Promise; public cacheId:string; public pollInterval:number; public releaseTimeout:number; public events = { accessViolation: 'accessViolation', logoutSuccess: 'logoutSuccess', logoutError: 'logoutError', authorizeSuccess: 'authorizeSuccess', authorizeError: 'authorizeError', refreshSuccess: 'refreshSuccess', refreshError: 'refreshError' }; static forcedTokenType = 'forced'; constructor(context:context.Context) { super(context); this.server = ''; this.apiKey = ''; this.account = '~'; this.urlPrefix = '/restapi'; this.apiVersion = 'v1.0'; this.accountPrefix = '/account/'; this.accessTokenTtl = null; // Platform server by default sets it to 60 * 60 = 1 hour this.refreshTokenTtl = 10 * 60 * 60; // 10 hours this.refreshTokenTtlRemember = 7 * 24 * 60 * 60; // 1 week this.refreshHandicapMs = 60 * 1000; // 1 minute this.refreshDelayMs = 100; this.clearCacheOnRefreshError = true; this.refreshPromise = null; this.cacheId = 'platform'; this.pollInterval = 250; this.releaseTimeout = 5000; // If queue was not released then force it to do so after some timeout } getStorage():cache.Cache { return cache.$get(this.context); } getRequest():request.Request { return request.$get(this.context); } clearStorage() { this.getStorage().clean(); return this; } setCredentials(appKey, appSecret) { var apiKey = (appKey || '') + ':' + (appSecret || ''); if (apiKey == ':') return this; this.apiKey = (typeof btoa == 'function') ? btoa(apiKey) : new Buffer(apiKey).toString('base64'); return this; } getCredentials():{key:string; secret:string} { var credentials = ( (typeof atob == 'function') ? atob(this.apiKey) : new Buffer(this.apiKey, 'base64').toString('utf-8') ).split(':'); return { key: credentials[0], secret: credentials[1] }; } setServer(server) { this.server = server || ''; return this; } remember(remember?:boolean):Platform { var key = this.cacheId + '-remember'; if (remember !== undefined) { this.getStorage().setItem(key, remember); return this; } return this.getStorage().getItem(key) || false; } getAuthURL(options:{ redirectUri:string; display?:string; // page|popup|touch|mobile, default 'page' prompt?:string; // sso|login|consent, default is 'login sso consent' state?:string; brandId?:string|number; }) { options = options || {}; return this.apiUrl('/restapi/oauth/authorize?' + this.utils.queryStringify({ 'response_type': 'code', 'redirect_uri': options.redirectUri || '', 'client_id': this.getCredentials().key, 'state': options.state || '', 'brand_id': options.brandId || '', 'display': options.display || '', 'prompt': options.prompt || '' }), {addServer: true}) } parseAuthRedirectUrl(url:string) { var qs = this.utils.parseQueryString(url.split('?').reverse()[0]), error = qs.error_description || qs.error; if (error) { var e = new Error(error); e.error = qs.error; throw e; } return qs; } authorize(options?:{ username?:string; password?: string; extension?:string; code?:string; redirectUri?:string; clientId?:string; remember?:boolean }) { options = options || {}; options.remember = options.remember || false; var body = { "access_token_ttl": this.accessTokenTtl, "refresh_token_ttl": options.remember ? this.refreshTokenTtlRemember : this.refreshTokenTtl }; if (options.username) { body.grant_type = 'password'; body.username = options.username; body.password = options.password; body.extension = options.extension || ''; } else if (options.code) { body.grant_type = 'authorization_code'; body.code = options.code; body.redirect_uri = options.redirectUri; //body.client_id = this.getCredentials().key; // not needed } else { return this.context.getPromise().reject(new Error('Unsupported authorization flow')); } return this.authCall({ url: '/restapi/oauth/token', post: body }).then((response) => { this.setCache(response.data) .remember(options.remember) .emitAndCallback(this.events.authorizeSuccess, []); return response; }).catch((e:request.IAjaxError):any => { this.clearStorage() .emitAndCallback(this.events.authorizeError, [e]); throw e; }); } isPaused() { var storage = this.getStorage(), cacheId = this.cacheId + '-refresh'; return !!storage.getItem(cacheId) && Date.now() - parseInt(storage.getItem(cacheId)) < this.releaseTimeout; } pause() { this.getStorage().setItem(this.cacheId + '-refresh', Date.now()); return this; } /** * If the queue is unpaused internally, polling will be cancelled * @returns {Platform} */ resume() { this.getStorage().removeItem(this.cacheId + '-refresh'); return this; } refresh() { var refresh = new (this.context.getPromise())((resolve, reject) => { if (this.isPaused()) { return resolve(this.refreshPolling(null)); } else { this.pause(); } // Make sure all existing AJAX calls had a chance to reach the server setTimeout(() => { var authData = this.getAuthData(); this.log.debug('Platform.refresh(): Performing token refresh (access token', authData.access_token, ', refresh token', authData.refresh_token, ')'); if (!authData || !authData.refresh_token) return reject(new Error('Refresh token is missing')); if (Date.now() > authData.refreshExpireTime) return reject(new Error('Refresh token has expired')); if (!this.isPaused()) return reject(new Error('Queue was resumed before refresh call')); resolve(this.authCall({ url: '/restapi/oauth/token', post: { "grant_type": "refresh_token", "refresh_token": authData.refresh_token, "access_token_ttl": this.accessTokenTtl, "refresh_token_ttl": this.remember() ? this.refreshTokenTtlRemember : this.refreshTokenTtl } })); }, this.refreshDelayMs); }); return refresh.then((response:r.Response) => { // This means refresh has happened elsewhere and we are here because of timeout if (!response || !response.data) return response; this.log.info('Platform.refresh(): Token was refreshed'); if (!response.data.refresh_token || !response.data.access_token) { var e = new Error('Malformed OAuth response'); e.ajax = response; throw e; } this.setCache(response.data) .resume(); return response; }).then((result) => { this.emit(this.events.refreshSuccess, result); return result; }).catch((e:request.IAjaxError):any => { if (this.clearCacheOnRefreshError) this.clearStorage(); this.emitAndCallback(this.events.accessViolation, [e]) .emitAndCallback(this.events.refreshError, [e]); throw e; }); } /** * @returns {Promise} */ logout():Promise { this.pause(); return this.authCall({ url: '/restapi/oauth/revoke', post: { token: this.getToken() } }).then((response) => { this.resume() .clearStorage() .emit(this.events.logoutSuccess, response); return response; }).catch((e:request.IAjaxError):any => { this.resume() .emitAndCallback(this.events.accessViolation, [e]) .emitAndCallback(this.events.logoutError, [e]); throw e; }); } refreshPolling(result) { if (this.refreshPromise) return this.refreshPromise; this.refreshPromise = new (this.context.getPromise())((resolve, reject) => { this.log.warn('Platform.refresh(): Refresh is already in progress polling started'); this.utils.poll((next) => { if (this.isPaused()) return next(); this.refreshPromise = null; this.resume(); if (this.isTokenValid()) { resolve(result); } else { reject(new Error('Automatic authentification timeout')); } }, this.pollInterval); }); return this.refreshPromise; } getToken() { return this.getAuthData().access_token; } getTokenType() { return this.getAuthData().token_type; } getAuthData():IPlatformAuthInfo { return this.getStorage().getItem(this.cacheId) || { token_type: '', access_token: '', expires_in: 0, refresh_token: '', refresh_token_expires_in: 0 }; } /** * Check if there is a valid (not expired) access token */ isTokenValid():boolean { var authData = this.getAuthData(); return (authData.token_type == Platform.forcedTokenType || (new Date(authData.expireTime).getTime() - this.refreshHandicapMs) > Date.now() && !this.isPaused()); } /** * Checks if user is authorized * If there is no access token, refresh will be performed */ isAuthorized():Promise { if (this.isTokenValid()) return this.context.getPromise().resolve(true); return this.refresh(); } cancelAccessToken() { return this.setCache(this.utils.extend(this.getAuthData(), { access_token: '', expires_in: 0 })); } setCache(authData:IPlatformAuthInfo) { var oldAuthData = this.getAuthData(); this.log.info('Platform.setCache(): Tokens were updated, new:', authData, ', old:', oldAuthData); authData.expireTime = Date.now() + (authData.expires_in * 1000); authData.refreshExpireTime = Date.now() + (authData.refresh_token_expires_in * 1000); this.getStorage().setItem(this.cacheId, authData); return this; } forceAuthentication() { this.setCache({ token_type: Platform.forcedTokenType, access_token: '', expires_in: 0, refresh_token: '', refresh_token_expires_in: 0 }); return this; } apiCall(options?:request.IAjaxOptions):Promise { options = options || {}; options.url = this.apiUrl(options.url, {addServer: true}); return this.isAuthorized().then(() => { // Refresh will occur inside var token = this.getToken(); return this.getRequest() .setOptions(options) .setHeader('Authorization', this.getTokenType() + (token ? ' ' + token : '')) .send(); }).catch((e:request.IAjaxError) => { if (!e.response || !e.response.isUnauthorized()) throw e; this.cancelAccessToken(); return this .refresh() .then(() => { // Re-send with same options return this.apiCall(options); }); }); } get(url:string, options?:request.IAjaxOptions) { options = options || {}; options.url = url; options.method = 'GET'; return this.apiCall(options); } post(url:string, options:request.IAjaxOptions) { options = options || {}; options.url = url; options.method = 'POST'; return this.apiCall(options); } put(url:string, options:request.IAjaxOptions) { options = options || {}; options.url = url; options.method = 'PUT'; return this.apiCall(options); } 'delete'(url:string, options?:request.IAjaxOptions) { options = options || {}; options.url = url; options.method = 'DELETE'; return this.apiCall(options); } authCall(options?:request.IAjaxOptions):Promise { options = options || {}; options.method = options.method || 'POST'; options.url = this.apiUrl(options.url, {addServer: true}); return this.getRequest() .setOptions(options) .setHeader('Content-Type', 'application/x-www-form-urlencoded') .setHeader('Accept', 'application/json') .setHeader('Authorization', 'Basic ' + this.apiKey) .send(); } apiUrl(url, options?:{addMethod?: string; addToken?: boolean; addServer?: boolean}):string { url = url || ''; options = options || {}; var builtUrl = '', hasHttp = url.indexOf('http://') != -1 || url.indexOf('https://') != -1; if (options.addServer && !hasHttp) builtUrl += this.server; if (url.indexOf(this.urlPrefix) == -1 && !hasHttp) builtUrl += this.urlPrefix + '/' + this.apiVersion; if (url.indexOf(this.accountPrefix) > -1) builtUrl.replace(this.accountPrefix + '~', this.accountPrefix + this.account); builtUrl += url; if (options.addMethod || options.addToken) builtUrl += (url.indexOf('?') > -1 ? '&' : '?'); if (options.addMethod) builtUrl += '_method=' + options.addMethod; if (options.addToken) builtUrl += (options.addMethod ? '&' : '') + 'access_token=' + this.getToken(); return builtUrl; } } export interface IAuthError extends Error { error?:string; } export interface IPlatformAuthInfo { token_type?:string; access_token?:string; expires_in?:number; // actually it's string expireTime?:number; refresh_token?:string; refresh_token_expires_in?:number; // actually it's string refreshExpireTime?:number; scope?:string; } export function $get(context:context.Context):Platform { return context.createSingleton('Platform', ()=> { return new Platform(context); }); }