@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
JavaScript
"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;