UNPKG

@pecureo/anvil-stream

Version:

Stream-Based Authorization framework for Anvil Connect

344 lines (281 loc) 11.1 kB
import { ReplaySubject } from 'rxjs'; import { Anvil, CryptoJS, JWS, b64utohex } from './anvil'; import { Session, ISessionData } from './session'; export interface IAuthorizationOptions { // Whether to close any popups used in the authorization process (defaults to // true). When disabled, requires additional logic in the authorization flow // callback handling to close the popup close_popup?: boolean; // Can be used to force a path on the cookie used to store the secret // encrypting the tokens stored in localStorage. This can be useful when // running the authorization flow from a sub-path of a domain while // authorizing for the entire domain force_path?: string; // The time in seconds for which the session may stay active max_age?: number; // By specifying a provider, the authorization process will only use that // specific provider instead of showing a selector providing access to all // identity providers available to the issuer provider?: string; replace_active_session?: boolean; // Setting to true implies replacing the active session retrieve_user_info?: boolean; [ index: string ]: any; } export interface IOidcConfiguration{ client_id: string; issuer: string; redirect_uri: string; client_secret?: string; } export class Issuer { public error$: ReplaySubject<Error> = new ReplaySubject<Error>(); public persisted_session: Session; public session$: ReplaySubject<Session>; private configuration: IOidcConfiguration; private sessions: Session[] = []; constructor(base_configuration: IOidcConfiguration) { this.configuration = base_configuration; this.session$ = new ReplaySubject(1); Anvil.configure(this.configuration); Anvil.getKeys(); if(Object.keys(Anvil.session).length > 0) { this.saveSession(Anvil.session); } } abortAllSessions() { this.sessions.forEach((session: Session) => session.abort()); } addAuthorizationHeaders(headers: Object = {}) { return Anvil.headers(headers); } authorizeFullPage( requested_scopes: string[] = [], options: IAuthorizationOptions ) { window.location.href = this.getAuthorizationUri( requested_scopes, options ); } authorizeWithPopup( requested_scopes: string[] = [], options: IAuthorizationOptions ) { // Set up framework to receive response from the popup when the // authorization flow finishes let authorization_popup: Window; let session_data_listener = (event: MessageEvent) => { // Not all received events contain authorization data. Not verifying this // can cause crashes if( event.data && typeof(event.data) === 'string' && event.data.includes('access_token') ) { if(!options || options.close_popup) { authorization_popup.close(); } window.removeEventListener('message', session_data_listener, false); this.processAuthorization(this.extractSessionDataFromUri(event.data)); } }; window.addEventListener('message', session_data_listener, false); // Open the popup with the authorization flow let authorization_uri = this.getAuthorizationUri( requested_scopes, options ); authorization_popup = window.open( authorization_uri, 'authorization', Anvil.popup(700, 500) ); } canProcessAuthorization() { return location.hash.includes('access_token'); } public extractSessionDataFromUri(uri: string) { return Anvil.parseFormUrlEncoded( uri.replace(/.+access_token/, 'access_token') ); } // This is mostly Anvil.callback. However, since that function has // verification and parsing of the authorization data mixed with actual // replacement of the session it cannot be used to manage multiple sessions processAuthorization( session_data = this.extractSessionDataFromUri(location.hash) ) { if(session_data['access_token']) { location.hash = ''; session_data = this.parseSessionOptions(session_data); if(session_data.error) { if(session_data.options.replace_active_session || session_data.options.retrieve_user_info) { Anvil.reset(); } Anvil.sessionState = session_data.session_state localStorage['anvil.connect.session.state'] = Anvil.sessionState; this.error$.next(new Error(session_data.error)); } else { let accessJWS = new JWS(); let idJWS = new JWS(); // Get the JWKs from localStorage. This should be readily available // once the constructor has run and executed Anvil.getKeys() try { var jwk = JSON.parse(localStorage['anvil.connect.jwk']); var hN = b64utohex(jwk.n); var hE = b64utohex(jwk.e); } catch(e) { this.error$.next( new Error('JWKs are unavailable, cannot process authorization') ); } // Decode the access token and verify signature if(session_data.access_token && !accessJWS.verifyJWSByNE(session_data.access_token, hN, hE)) { this.error$.next(new Error('Failed to verify access token signature')); } // Decode the id token and verify signature if(session_data.id_token && !idJWS.verifyJWSByNE(session_data.id_token, hN, hE)) { this.error$.next(new Error('Failed to verify id token signature')); } // Parse the access token payload try { session_data.access_claims = JSON.parse( accessJWS.parsedJWS.payloadS ); } catch(e) { this.error$.next(new Error('Cannot parse access token payload')); } // Parse the id token payload try { session_data.id_claims = JSON.parse(idJWS.parsedJWS.payloadS); } catch(e) { this.error$.next(new Error('Cannot parse id token payload')); } // Validate the nonce if(session_data.id_claims && !Anvil.nonce(session_data.id_claims.nonce)) { this.error$.next(new Error('Invalid nonce')); } // Verify at_hash let atHash = CryptoJS.SHA256(session_data.access_token) .toString(CryptoJS.enc.Hex); atHash = atHash.slice(0, atHash.length / 2); if(session_data.id_claims && atHash !== session_data.id_claims.at_hash) { this.error$.next( new Error('Invalid access token hash in id token payload') ); } Anvil.sessionState = session_data.session_state; if(session_data.options.retrieve_user_info) { this.persistSession(session_data); Anvil.userInfo().then((user_info: Object) => { session_data.user_info = user_info; this.persistSession(session_data); this.saveSession(session_data); }).catch((error: string) => this.error$.next(new Error(error))); } else { if(session_data.options.replace_active_session){ this.persistSession(session_data); } this.saveSession(session_data); } } } else { this.error$.next(new Error('No authorization data available')); } } removeSession(session: Session) { if(this.persisted_session === session) { this.persisted_session = null; } this.sessions = this.sessions.filter( stored_session => stored_session !== session ); } private getAuthorizationUri( requested_scopes: string[] = [], options: IAuthorizationOptions ) { // Temporarily reconfigure Anvil to authorize the requested scopes Anvil.configure(Object.assign( { scope: requested_scopes }, this.configuration )); let authorization_uri = Anvil.uri(); if(options) { if(options.provider) { authorization_uri = Anvil.uri(`connect/${options.provider}`); } if(options.force_path) { options.force_path = encodeURIComponent(options.force_path); authorization_uri += `&force_path=${options.force_path}`; } if(options.max_age) { authorization_uri += `&max_age=${options.max_age}`; delete options.max_age; } authorization_uri += `&state=${JSON.stringify(options)}`; } // Reset back to base configuration Anvil.configure(this.configuration); return authorization_uri; } private parseSessionOptions(session_data: ISessionData) { session_data.options = { replace_active_session: true, retrieve_user_info: false }; if(session_data.state) { try { let received_options = JSON.parse(session_data.state); Object.assign(session_data.options, received_options); } catch(exception) { this.error$.next(new Error('Could not parse session state')); } } return session_data; } // Store session data across page reloads private persistSession(session_data: ISessionData) { Anvil.session = session_data; Anvil.serialize(); // The `serialize` method stores the secret for the currently active path. // The path can be forced to a given path by setting the appropriate // option, but this needs to be updated after the secret is serialized for // the default path (or it will not be available) if(session_data.options.force_path) { // Token expirations are in seconds, JavaScript uses milliseconds let expires = new Date(session_data.id_claims.exp * 1000).toUTCString(); let path = decodeURIComponent(session_data.options.force_path); let secret = document.cookie.replace( /(?:(?:^|.*;\s*)anvil.connect\s*\=\s*([^;]*).*$)|^.*$/, "$1" ); // Expire the original cookie. This has to be done first, to prevent the // cookie for the forced path to be removed in case the forced path // happens to coincide with the original path document.cookie = `anvil.connect=clear; expires=${new Date(0).toUTCString()}`; // Set the new cookie with the previous cookies secret and expiration // date, but the path forced to the desired path document.cookie = `anvil.connect=${secret}; expires=${expires}; path=${path}`; } } // Store a reference to the session for the current page load private saveSession(session_data: ISessionData) { const session = new Session(this, session_data); if(!session.expired) { this.sessions.push(session); // Store a separate reference to the persisted (main) authorization session // for the Issuer if(session_data.options.replace_active_session || session_data.options.retrieve_user_info) { this.persisted_session = this.sessions[this.sessions.length - 1]; } this.session$.next(this.sessions[this.sessions.length - 1]); } else { this.error$.next(new Error( 'Processed data for an expired session... Please verify the ' + 'specified max_age and/or check for clock skew on the systems ' + 'involved in the authorization flow' )); } } }