@pecureo/anvil-stream
Version:
Stream-Based Authorization framework for Anvil Connect
344 lines (281 loc) • 11.1 kB
text/typescript
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'
));
}
}
}