UNPKG

@obelisk/client

Version:

Typescript client to interact with Obelisk on a higher level than the regular ReST API calls.

716 lines (715 loc) 30.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ObeliskClient = void 0; const rxjs_1 = require("rxjs"); const ajax_1 = require("rxjs/ajax"); const operators_1 = require("rxjs/operators"); const api_1 = require("./api"); const auth_1 = require("./auth"); const interfaces_1 = require("./interfaces"); const util_1 = require("./util"); /** * The ObeliskClient is the main entrypoint for using the library. */ class ObeliskClient { /** * Create a new client. **You need to call init() before using it!** * @param options The ClientOptions object containing all setup configuration * @param eventObserver Optionally you can add an observer that listens to the ClientEvents from the very start. */ constructor(optionsObjectOrUrl, eventObserver) { this.UMA2CONFIG_PATH = '/.well-known/uma2-configuration'; this.useOfflineToken = false; this.defaultOptions = { authMode: 'entitlement', flow: 'standard', }; this.storeTokens = (resp, hasState, offlineLoginHandling) => { if (resp.status >= 400) { this.logout(); return rxjs_1.of(false); } const authResponse = resp.response; const pat = new auth_1.Token(authResponse.access_token); const patRefresh = new auth_1.Token(authResponse.refresh_token); const idtok = new auth_1.Token(authResponse.id_token); // check nonces if (!offlineLoginHandling) { if (!this.isNonceValid(pat.getParsedToken().nonce)) { console.log('[IoT-CLIENT] Invalid nonce, clearing token'); this.clearTokens(); this.authOver$.next(); return rxjs_1.of(false); } if (!this.isNonceValid(patRefresh.getParsedToken().nonce)) { console.log('[IoT-CLIENT] Invalid nonce, clearing token'); this.clearTokens(); this.authOver$.next(); return rxjs_1.of(false); } if (!this.isNonceValid(idtok.getParsedToken().nonce)) { console.log('[IoT-CLIENT] Invalid nonce, clearing token'); this.clearTokens(); this.authOver$.next(); return rxjs_1.of(false); } } // store in memory this._tokens.pat = pat; this._tokens.patRefresh = patRefresh; this._tokens.idtoken = idtok; // If scope includes offline_access and refresh_expires_in=== 0 (store in localstorage to skip login next time) if (authResponse.scope.split(' ').includes('offline_access') && authResponse.refresh_expires_in <= 0) { // this._storage!.add('logInfo', { authenticated: true, expires: -1, offline_token: patRefresh.getToken() }); // console.log({token: patRefresh.getToken()}); const t = { token: patRefresh.getToken() }; this._storage.addRaw('offline', t); } // store logged in + expiration // this._storage!.add('logInfo', { authenticated: true, expires: pat.getExpiresAt() }); this.updateLogInfo(pat); // this.scheduleTokenRefresh(pat, patRefresh); this.authOver$.next(); if (hasState) { // If modern browser, insert querystring without reload // if (history.pushState) { const newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + decodeURIComponent(escape(atob(decodeURIComponent(hasState)))); window.history.pushState({ path: newurl }, '', newurl); // } } return rxjs_1.of(true); }; this._events$ = new rxjs_1.Subject(); // Set init mode to url or cfg based if (typeof optionsObjectOrUrl === 'string') { this._initMode = 'url'; this._optionsUrl = optionsObjectOrUrl; } else { this._initMode = 'cfg'; this._optionsObject = optionsObjectOrUrl; } if (eventObserver) { this._events$.subscribe(eventObserver); } this._tokens = {}; this.authOver$ = new rxjs_1.ReplaySubject(1); this.CRED_KEY = window.origin + '/cr'; } getConfig() { if (!this._config$) { this._config$ = this._initMode === 'url' ? ajax_1.ajax.getJSON(this._optionsUrl).pipe(operators_1.shareReplay(1)) : rxjs_1.of(this._optionsObject).pipe(operators_1.shareReplay(1)); } return this._config$.pipe(operators_1.map(cfg => (Object.assign(Object.assign({}, this.defaultOptions), cfg)))); } /** * @inheritdoc */ init() { return this.getConfig().pipe(operators_1.tap(cfg => { this._options = cfg; this._storage = util_1.Storage.create(cfg); }), operators_1.flatMap(options => this.getUma2Config(options)), operators_1.tap(uma2Config => this._uma2Config = uma2Config), operators_1.flatMap(_ => this.checkHashForLogin(window.location.href)), operators_1.catchError(res => { if (res.status && res.status >= 400) { this.logout(); } return rxjs_1.of(false); })); } /** * @inheritdoc */ createLogoutUrl(redirectUri) { this.checkInit(); const red_uri = redirectUri || window.location.href; let params = '?redirect_uri=' + encodeURIComponent(red_uri); return this._uma2Config.end_session_endpoint + params; } /** * @inheritdoc */ createLoginUrl(loginOptions) { this.checkInit(); const opt = this._options; let red_uri = loginOptions && loginOptions.redirectUri || window.location.href; red_uri = red_uri.endsWith('#') ? red_uri.slice(0, red_uri.length - 1) : red_uri; // Capture queryString if present const idx = red_uri.indexOf('?'); let queryString = null; if (idx !== -1) { queryString = red_uri.slice(idx); red_uri = red_uri.slice(0, idx); } const prompt = loginOptions && loginOptions.prompt; let params = '?'; params += 'client_id=' + encodeURIComponent(opt.clientId); params += '&redirect_uri=' + encodeURIComponent(this.normalizeRedirectUri(red_uri)); // In case of queryString, add as state if (queryString !== null) { params += '&state=' + btoa(unescape(encodeURIComponent(queryString))); } params += '&nonce=' + encodeURIComponent(this.generateNonce()); params += '&scope=' + encodeURIComponent('openid'); if (loginOptions && loginOptions.offline_access) { params += encodeURIComponent(' offline_access'); } params += '&response_mode=fragment'; switch (this._options.flow) { case 'implicit': params += '&response_type=' + encodeURIComponent('id_token token'); break; case 'standard': params += '&response_type=' + encodeURIComponent('code'); break; default: throw 'Invalid value for flow'; } if (prompt) { params += '&prompt=' + encodeURIComponent(prompt); } // Set nonce and save to securityMap return this._uma2Config.authorization_endpoint + params; } storeClientCredentials(clientId, clientSecret) { const cred = { clientId, clientSecret }; sessionStorage.setItem(this.CRED_KEY, btoa(unescape(encodeURIComponent(JSON.stringify(cred))))); } loadClientCredentials() { const cred = sessionStorage.getItem(this.CRED_KEY); if (!cred) { throw 'No credentials in session'; } return JSON.parse(decodeURIComponent(escape(atob(cred)))); } /** * Client credentials will be cleared from Session storage, if they were present */ clearClientCredentials() { sessionStorage.removeItem(this.CRED_KEY); } /** * @inheritdoc */ loginAsClient(clientId, clientSecret) { this.checkInit(); // this.options.clientId = clientId; // Store in session storage this.storeClientCredentials(clientId, clientSecret); const tokenUrl = this._uma2Config.token_endpoint; const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; let params = `grant_type=client_credentials`; // const redUri = window.location.origin + window.location.pathname; params += '&client_id=' + encodeURIComponent(clientId); params += '&client_secret=' + encodeURIComponent(clientSecret); params += '&scope=openid'; // params += '&redirect_uri=' + redUri; return ajax_1.ajax.post(tokenUrl, params, headers).pipe(operators_1.catchError(err => rxjs_1.of(err)), operators_1.flatMap(resp => { if (resp.status >= 400) { this.clearClientCredentials(); return rxjs_1.of(false); } else { const authResponse = resp.response; const pat = new auth_1.Token(authResponse.access_token); const patRefresh = new auth_1.Token(authResponse.refresh_token); const idtok = new auth_1.Token(authResponse.id_token); // store in memory this._tokens.pat = pat; this._tokens.patRefresh = patRefresh; this._tokens.idtoken = idtok; // store logged in + expiration this._storage.add('logInfo', { authenticated: true, expires: pat.getExpiresAt() }); // this.scheduleTokenRefresh(pat, patRefresh); this.authOver$.next(); return rxjs_1.of(true); } }), operators_1.switchMap(() => this.getNewRpt())); } /** * @inheritdoc */ login(loginOptions) { window.location.href = this.createLoginUrl(loginOptions); } /** * @inheritdoc */ logout(redirectUri) { this.clearTokens(); this.clearClientCredentials(); window.location.href = this.createLogoutUrl(redirectUri); } /** * @inheritdoc */ temporalPageEndpoint(uri, apiVersion = 'v1') { if (util_1.InternalUtils.isTPageCompatible(this, uri, apiVersion)) { return api_1.TPageEndpoint.create(this, uri, apiVersion); } else { throw new Error('Not a Temporal Page compatible endpoint: ' + util_1.InternalUtils.norm(this, util_1.InternalUtils.part(uri)[0])); } } /** * @inheritdoc * */ endpoint(uri, apiVersion = 'v1') { util_1.Logger.debug(uri, 'AJAX'); if (apiVersion === 'v2' || !util_1.InternalUtils.isTPageCompatible(this, uri, apiVersion)) { return api_1.Endpoint.create(this, uri, apiVersion); } else { throw new Error('Not a metadata compatible endpoint: ' + util_1.InternalUtils.norm(this, uri)); } } /** * @inheritdoc */ streamEndpoint(uri, apiVersion = 'v1') { if (util_1.InternalUtils.isStreamEndpointCompatible(this, uri, apiVersion)) { return api_1.StreamEndpoint.create(this, uri, apiVersion); } else { throw new Error('Not a Stream compatible endpoint: ' + util_1.InternalUtils.norm(this, uri)); } } /** * @inheritdoc */ graphQLEndpoint() { return api_1.GraphQLEndpoint.create(this); } /** * @inheritdoc * */ rawEndpoint(uri, apiVersion = 'v1') { util_1.Logger.debug(uri, 'AJAX'); return api_1.Endpoint.create(this, uri, apiVersion); } /** * @inheritdoc */ isLoggedIn() { const logInfo = this._storage.get('logInfo'); const ok = logInfo && logInfo.authenticated && Date.now() <= logInfo.expires; return ok; } /** * @inheritdoc */ rptHasRole(role, targetClientId) { if (this._tokens && this._tokens.rpt) { const clientId = targetClientId ? targetClientId : 'policy-enforcer'; const rpt = this._tokens.rpt.getParsedToken(); if (clientId in rpt.resource_access) { const roles = rpt.resource_access[clientId].roles; return roles.indexOf(role) !== -1; } else { return false; } } else { return false; } } /** * Check authenticated status in cookies. If loggedIn and no PAT token present, try to login silently. */ isAuthenticated() { // reseting useOfflineToken this.useOfflineToken = false; // check storage try { const recentlyLoggedIn = this.isLoggedIn(); if (this._tokens.pat === undefined && recentlyLoggedIn && !this._storage.get('offline')) { console.debug('no offline token, trying silent login'); util_1.Logger.debug('No PAT and loggedIn in storage: Try to log in silently', 'AUTHN'); this.login({ prompt: 'none' }); } else { console.debug('offline token, trying refresh login'); const offline = this._storage.get('offline'); if (offline && offline.token) { // Set offline token usage to true this.useOfflineToken = true; // console.debug('--trying offline_token login'); const url = this._uma2Config.token_endpoint; const clientId = this._options.clientId; const headers = { 'Content-Type': 'application/x-www-form-urlencoded', }; const params = `client_id=${clientId}&grant_type=refresh_token&refresh_token=${offline.token}`; return ajax_1.ajax.post(url, params, headers).pipe( // tap(console.log, console.log), // IN ERROR STREAM operators_1.flatMap(resp => this.storeTokens(resp, null, true))); } } if (!recentlyLoggedIn) { // Check if there are clientCredentials present try { const cred = this.loadClientCredentials(); // Client credentials present, lets login with them return this.loginAsClient(cred.clientId, cred.clientSecret).pipe(operators_1.map(token => !!token), operators_1.tap(_ => this.authOver$.next())); } catch (err) { // No client credentials, just proceed as normal this.authOver$.next(); return rxjs_1.of(recentlyLoggedIn); } } else { this.authOver$.next(); return rxjs_1.of(recentlyLoggedIn); } } catch (err) { console.log(err); this.authOver$.next(); return rxjs_1.of(false); } } /** * Clears all tokens. Including session storage client credentials. */ clearTokens() { this._storage.clearAll(); this.useOfflineToken = false; this._tokens = {}; } /** * Checks whether the init function has been called yet. */ checkInit() { if (this._uma2Config === null || this._uma2Config === undefined) { throw new Error("Uma2 Config not found! Have you called init() first?"); } } /** * Requests the Uma2 config from the wellknown url. */ getUma2Config(options) { const url = options.host + `/auth/realms/${options.realm}${this.UMA2CONFIG_PATH}`; return ajax_1.ajax.getJSON(url); } updateLogInfo(token) { // store logged in + expiration // console.debug('--updateing logInfo expires date for new RPT'); this._storage.add('logInfo', { authenticated: true, expires: token.getExpiresAt() }); } /** * Checks a full url for a hash that can be parsed to a TokenResponse. * @param url Full url with hash */ checkHashForLogin(url) { // On error, clear hash, clear tokens if (window.location.hash.indexOf('error=login_required') !== -1) { window.location.hash = ''; this.clearTokens(); this.authOver$.next(); return this.isAuthenticated(); //of(false); } try { const authResponse = new auth_1.TokenResponse(url, this._options.flow); window.location.hash = ''; // Load in oauth state object if it is present this._oauth = this._storage.get('oauth', true); if (this._options.flow === 'implicit') { util_1.Logger.debug('Implicit flow', 'AUTHN'); const pat = new auth_1.Token(authResponse.access_token); const idtok = new auth_1.Token(authResponse.id_token); // check nonces if (!this.isNonceValid(pat.getParsedToken().nonce)) { console.log('[IoT-CLIENT] Invalid nonce, clearing token'); this.clearTokens(); return rxjs_1.of(false); } if (!this.isNonceValid(idtok.getParsedToken().nonce)) { console.log('[IoT-CLIENT] Invalid nonce, clearing token'); this.clearTokens(); return rxjs_1.of(false); } // store in memory this._tokens.pat = pat; this._tokens.idtoken = idtok; // store logged in + expiration // this._storage!.add('logInfo', { authenticated: true, expires: pat.getExpiresAt() }); this.updateLogInfo(pat); this.authOver$.next(); return rxjs_1.of(true); } else if (this._options.flow === 'standard') { util_1.Logger.debug('Standard flow', 'AUTHN'); const tokenUrl = this._uma2Config.token_endpoint; const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; const hasState = authResponse.state || null; let params = `code=${authResponse.code}&grant_type=authorization_code`; let redUri = window.location.origin + window.location.pathname; params += '&client_id=' + encodeURIComponent(this._options.clientId); params += '&redirect_uri=' + this.normalizeRedirectUri(redUri); return ajax_1.ajax.post(tokenUrl, params, headers).pipe(operators_1.flatMap(resp => this.storeTokens(resp, hasState, false))); } else { this.authOver$.next(); return rxjs_1.of(false); } } catch (err) { // No fragment error: means it is a regular page load => check auth status in storage via is Authenticated return this.isAuthenticated(); } ; } /** * Removes last slash if present * @param uri */ normalizeRedirectUri(uri) { if (uri.endsWith('/')) { return uri.slice(0, uri.length - 1); } else { return uri; } } /** * @hidden * TODO: Schedules a token refresh WITHIN the expiry date timeframe. Useful for implicit token refreshes. * @param pat * @param patRefresh */ // private scheduleTokenRefresh(pat: Token, patRefresh: Token): void { // // Get date from pat token // const expiry = pat.getExpiresAt(); // // Refresh 30 seconds before expiry // const refreshTime = expiry - (30 * 1000); // // Use patRefresh to refresh // setTimeout(() => { // // TODO: onTokenExpired callback // }, refreshTime - new Date().getTime()); // } /** * @inheritdoc */ keepSessionAlive(leeway = 3000) { return this.scheduleRefreshRpt(leeway).pipe(operators_1.expand(_ => this.scheduleRefreshRpt(leeway))); } /** * Internal call that defers execution of refresh logic until RPT expires (- minus some leeway in ms) * @param leeway The leeway in ms (default to 3000ms) */ scheduleRefreshRpt(leeway = 3000) { return rxjs_1.defer(() => { const delay = Math.max(0, this._tokens.rpt.getExpiresAt() * 1000 - Date.now() - leeway); return rxjs_1.timer(delay).pipe(operators_1.flatMap(_ => this.refreshRptToken())); }); } /** * Only for refreshing the PAT token. **Only meant for some specific cases** */ refreshPatToken() { const url = this._uma2Config.token_endpoint; const clientId = this._options.clientId; if (this._tokens && this._tokens.patRefresh) { const tok = this._tokens.patRefresh; const headers = { 'Content-Type': 'application/x-www-form-urlencoded', }; const params = `client_id=${clientId}&grant_type=refresh_token&refresh_token=${tok.getToken()}`; return ajax_1.ajax.post(url, params, headers).pipe(operators_1.flatMap(resp => { if (resp.status === 200) { const body = resp.response; this._tokens.pat = new auth_1.Token(body.access_token); this._tokens.patRefresh = new auth_1.Token(body.refresh_token); this._tokens.idtoken = new auth_1.Token(body.id_token); return rxjs_1.of(true); } else { return rxjs_1.throwError(resp.status + ' ' + resp.responseText); } })); } else { return rxjs_1.of(false); } } /** * Only for refreshing the RPT token. **Only meant for some specific cases** */ refreshRptToken() { if (!this.useOfflineToken) { // console.log('== Normal online refresh'); const url = this._uma2Config.token_endpoint; if (this._tokens && this._tokens.rptRefresh) { const tok = this._tokens.rptRefresh; const headers = { 'Content-Type': 'application/x-www-form-urlencoded', }; let params = `grant_type=refresh_token&refresh_token=${tok.getToken()}`; // if (useClientCredentials) { try { const cred = this.loadClientCredentials(); params += `&client_id=${cred.clientId}&client_secret=${cred.clientSecret}`; } catch (_a) { // Credentials are not present, use just client_id const clientId = this._options.clientId; params += `&client_id=${clientId}`; // this.logout(); // return of(false); } // } else { // const clientId = this._options!.clientId; // params += `&client_id=${clientId}`; // } return ajax_1.ajax.post(url, params, headers).pipe(operators_1.flatMap(resp => { if (resp.status === 200) { const body = resp.response; this._tokens.rpt = new auth_1.Token(body.access_token); this._tokens.rptRefresh = new auth_1.Token(body.refresh_token); this._tokens.idtoken = new auth_1.Token(body.id_token); // ADDED this.updateLogInfo(this._tokens.rpt); this._events$.next({ type: interfaces_1.ClientEventType.OnRptChanged }); this._events$.next({ type: interfaces_1.ClientEventType.OnRolesChanged }); return rxjs_1.of(true); } else { return rxjs_1.throwError(resp.status + ' ' + resp.responseText); } })); } else { return rxjs_1.of(false); } } else { return this.refreshPatToken().pipe(operators_1.flatMap(succeeded => succeeded ? this.getNewRpt().pipe(operators_1.map(_ => true)) : rxjs_1.of(false))); } } /** * @inheritdoc **/ getNewRpt(ticket) { const template = { metadata: { responseIncludeResourceName: false }, }; const tok = !!this._tokens.pat ? this._tokens.pat.getToken() : null; if (!tok) { return rxjs_1.throwError('No PAT token present. Either a page refresh is pending, or you did not init the client..'); } const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `Bearer ${tok}` }; let body = 'grant_type=urn:ietf:params:oauth:grant-type:uma-ticket'; body += '&client_id=' + this._options.clientId; body += '&audience=policy-enforcer'; switch (this._options.authMode) { case 'uma': util_1.Logger.debug('Getting new UMA RPT', 'AUTH'); body += "&ticket=" + ticket; // Incremental RPT auth if not explicitely turned of and RPT is present if (this._tokens.rpt && (template.incrementalAuthorization == undefined || template.incrementalAuthorization)) { body += '&rpt=' + this._tokens.rpt.getToken(); } return ajax_1.ajax.post(this._uma2Config.token_endpoint, body, headers).pipe(operators_1.map(resp => { const rpt = resp.response; this._tokens.rpt = new auth_1.Token(rpt.access_token); this._tokens.rptRefresh = new auth_1.Token(rpt.refresh_token); // ADDED this.updateLogInfo(this._tokens.rpt); this._events$.next({ type: interfaces_1.ClientEventType.OnRptChanged }); this._events$.next({ type: interfaces_1.ClientEventType.OnRolesChanged }); return this._tokens.rpt; })); default: case 'entitlement': util_1.Logger.debug('Getting new ENTITLEMENT RPT', 'AUTH'); let metadata = template.metadata; if (metadata) { if (metadata.responseIncludeResourceName) { body += "&response_include_resource_name=" + metadata.responseIncludeResourceName; } if (metadata.responsePermissionsLimit) { body += "&response_permissions_limit=" + metadata.responsePermissionsLimit; } } return ajax_1.ajax.post(this._uma2Config.token_endpoint, body, headers).pipe(operators_1.map(resp => { const rpt = resp.response; this._tokens.rpt = new auth_1.Token(rpt.access_token); this._tokens.rptRefresh = new auth_1.Token(rpt.refresh_token); // ADDED this.updateLogInfo(this._tokens.rpt); this._events$.next({ type: interfaces_1.ClientEventType.OnRptChanged }); this._events$.next({ type: interfaces_1.ClientEventType.OnRolesChanged }); return this._tokens.rpt; })); } } ; /** * Generates a new nonce and stores it in storage. */ generateNonce() { const oauth = this._storage.get('oauth') || {}; oauth.nonce = util_1.InternalUtils.createUUID(); this._storage.add('oauth', oauth); return oauth.nonce; } /** * Checks whether the given nonce matches the last generated one. * @param nonce Nonce to check */ isNonceValid(nonce) { return this._oauth && this._oauth.nonce && (this._oauth.nonce === nonce); } /** * Generates a new state and stores it in storage */ generateState() { const oauth = this._storage.get('oauth') || {}; oauth.state = util_1.InternalUtils.createUUID(); this._storage.add('oauth', oauth); return oauth.state; } /** * Checks whether the given state matches the last generated one. * @param state State to check */ isStateValid(state) { return this._oauth && this._oauth.state && (this._oauth.state === state); } /** * @inheritdoc */ get options() { return this._options; } /** * @inheritdoc */ get tokens() { return this._tokens; } /** * @inheritdoc */ get events() { return this._events$; } /** * @inheritdoc */ get isAuthReady$() { return this.authOver$; } /** * @internal * @hidden * */ get events$() { return this._events$; } } exports.ObeliskClient = ObeliskClient;