frontend/app/OAuthCallbackComponent.jsx (303 lines of code) (raw):

import React from "react"; import PropTypes from "prop-types"; import { loadInSigningKey, validateAndDecode } from "./JwtHelpers.jsx"; import { Redirect } from "react-router"; import LoadingIndicator from "./LoadingIndicator.jsx"; import {VError} from "ts-interface-checker"; import OAuthConfiguration from "./OAuthConfiguration"; function delayedRequest(url, timeoutDelay, token) { return new Promise((resolve, reject) => { const timerId = window.setTimeout(() => { console.error("Request timed out, could not contact UserBeacon"); resolve(); }, timeoutDelay); fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${token}`, body: "" }, }) .then((response) => { try { window.clearTimeout(timerId); } catch (err) { console.error("Could not clear the time out: ", err); } if (response.status === 200) { console.log("UserBeacon contacted successfully"); } else { console.log("UserBeacon returned an error: ", response.status); } resolve(); }) .catch((err) => { try { window.clearTimeout(timerId); } catch (error) { console.error("Could not clear the time out: ", error); } console.error("Could not contact UserBeacon: ", err); reject(err); }); }); } /** * this component handles the token redirect from the authentication * once the user has authed successfully with the IdP, the browser is sent a redirect * that lands here. We are given an opaque code by the server in the "code" query parameter. * We take this and try to exchange it for a bearer token; if successful this is stored into * the local storage and we then redirect the user back to what they were doing (via the State parameter) * If not successful, we halt and display an error message. */ class OAuthCallbackComponent extends React.Component { static propTypes = { oAuthUri: PropTypes.string.isRequired, tokenUri: PropTypes.string.isRequired, clientId: PropTypes.string.isRequired, redirectUri: PropTypes.string.isRequired, scope: PropTypes.string.isRequired, }; constructor(props) { super(props); this.state = { stage: 0, authCode: null, state: "/", token: null, refreshToken: null, expiry: null, haveClientId: false, lastError: null, inProgress: true, doRedirect: false, decodedContent: "", signingKey: "", errorInURL: false, showingLink: false, keyURL: "" }; this.validateAndDecode = this.validateAndDecode.bind(this); } setStatePromise(newState) { return new Promise((resolve, reject) => this.setState(newState, () => { console.debug("setState done", newState); resolve(); }) ); } /** * perform the validation of the token via jsonwebtoken library. * if validation fails then the returned promise is rejected * if validation succeeds, then the promise only completes once the decoded content has been set into the state. * @returns {Promise<unknown>} */ async validateAndDecode() { console.log("loading in signing key"); let signingKey = this.state.signingKey; if (!signingKey) { signingKey = await loadInSigningKey(); await this.setStatePromise({ signingKey: signingKey }); } try { const response = await fetch("/meta/oauth/config.json"); if (response.status === 200) { const data = await response.json(); const config = new OAuthConfiguration(data); //validates the configuration and throws a VError if it fails await this.setStatePromise({ keyURL: config.tokenSigningCertPath }); } else { throw `Server returned ${response.status}`; } } catch (err) { if (err instanceof VError) { console.log("OAuth configuration was not valid: ", err); } else { console.log("Could not load oauth configuration: ", err); } } try { console.log("Token from state: " + this.state.token); const decoded = await validateAndDecode( this.state.token, this.state.signingKey, this.state.keyURL, this.state.refreshToken ); return this.setStatePromise({ decodedContent: JSON.stringify(decoded), stage: 3, }); } catch (err) { console.error("could not decode JWT: ", err); return this.setStatePromise({ lastError: err.toString() }); } } /** * gets the auth code parameter from the URL query string and stores it in the state * @returns {Promise<unknown>} */ async loadInAuthcode() { const paramParts = new URLSearchParams(this.props.location.search); //FIXME: handle incoming error messages too return this.setStatePromise({ stage: 1, authCode: paramParts.get("code"), state: paramParts.get("state"), errorInURL: paramParts.get("error") ? true : false, }); } /** * performs the second-stage exchange, i.e. it sends the code back to the server and requests a bearer * token in response * @returns {Promise<void>} */ async requestToken() { //wait for ui to update before continuing await function () { return new Promise((resolve, reject) => window.setTimeout(() => resolve(), 500) ); }; const postdata = { grant_type: "authorization_code", client_id: this.props.clientId, redirect_uri: this.props.redirectUri, code: this.state.authCode, code_verifier: sessionStorage.getItem("cx") }; console.log("passed client_id ", this.props.clientId); const content_elements = Object.keys(postdata).map( (k) => k + "=" + encodeURIComponent(postdata[k]) ); const body_content = content_elements.join("&"); const response = await fetch(this.props.tokenUri, { method: "POST", body: body_content, headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, }); switch (response.status) { case 200: const content = await response.json(); console.log("received JWT"); return this.setStatePromise({ stage: 2, token: content.id_token ?? content.access_token, refreshToken: content.hasOwnProperty("refresh_token") ? content.refresh_token : null, expiry: content.expires_in, inProgress: false, }); default: const errorContent = await response.text(); console.log( "token endpoint returned ", response.status, ": ", errorContent ); return this.setStatePromise({ lastError: errorContent, inProgress: false, }); } } async componentDidUpdate(prevProps, prevState, snapshot) { //if the clientId is set when we are ready for it (stage==1), then action straightaway. //Otherwise it will be picked up in componentDidMount after stage 1 completes. if ( prevProps.clientId === "" && this.props.clientId !== "" && this.state.stage === 1 ) { try { await this.requestToken(); } catch (err) { console.error("requestToken failed: ", err); this.setState({ lastError: err.toString(), inProgress: false }); } } if (this.state.stage === 2) { console.log("validateAndDecode"); await this.validateAndDecode(); } } async componentDidMount() { await this.loadInAuthcode(); if (this.props.clientId !== "") { await this.requestToken().catch((err) => { console.error("requestToken failed: ", err); this.setState({ lastError: err.toString(), inProgress: false }); }); } window.setTimeout(() => this.setState({ showingLink: true }), 8000); } generateCodeChallenge() { const array = new Uint8Array(32); crypto.getRandomValues(array); const str = array.reduce((acc, x) => acc + x.toString(16).padStart(2, '0'), ""); sessionStorage.setItem("cx", str); return str; } makeLoginURL() { const args = { response_type: "code", client_id: this.props.clientId, redirect_uri: this.props.redirectUri, scope: this.props.scope, state: "/", }; const encoded = Object.entries(args).map( ([k, v]) => `${k}=${encodeURIComponent(v)}` ); return this.props.oAuthUri + "?" + encoded.join("&") + "&code_challenge=" + this.generateCodeChallenge(); } render() { let newLocation = ""; if (this.state.stage === 3) { newLocation = this.state.state ?? "/"; console.log(newLocation, this.state); window.location.href = newLocation; } return ( <div> {this.state.errorInURL ? ( <div className="error_centered"> <p className="URL_error">There was an error when logging in.</p> {this.state.showingLink ? ( <a href={this.makeLoginURL()}>Attempt to log in again</a> ) : ( <LoadingIndicator messageText="Please wait" /> )} </div> ) : ( <div className="centered" style={{ display: this.state.inProgress ? "flex" : "none" }} > <LoadingIndicator /> <p style={{ flex: 1, display: this.state.inProgress ? "inherit" : "none", }} > {this.state.stage === 3 ? `Login completed, sending you to ${newLocation}` : "Logging you in..."} </p> <p className="error" style={{ display: this.state.lastError ? "inherit" : "none" }} > Uh-oh, something went wrong: {this.state.lastError} </p> </div> )} </div> ); } } export { delayedRequest }; export default OAuthCallbackComponent;