From fedb37501c90ee4dee724e7b4a5c669f7178ca12 Mon Sep 17 00:00:00 2001 From: Wietse Wind Date: Mon, 7 Nov 2022 14:28:32 +0100 Subject: [PATCH] Add JWT re-use + logout, add page refresh support with existing JWT --- sample/index.html | 11 +++- src/index.ts | 151 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/sample/index.html b/sample/index.html index bbe103b..aaadd2b 100644 --- a/sample/index.html +++ b/sample/index.html @@ -38,6 +38,7 @@

Hello, world!

Signed in :)
+ @@ -74,6 +75,8 @@

Hello, world!

document.getElementById('signedin').style.display = 'none' document.getElementById('error').style.display = 'none' document.getElementById('trypayload').style.display = 'none' + document.getElementById('logout').style.display = 'none' + document.getElementById('results').style.display = 'none' } // Start in default UI state @@ -102,6 +105,7 @@

Hello, world!

resultspre.style.display = 'block' resultspre.innerText = JSON.stringify(authorized.me, null, 2) document.getElementById('trypayload').style.display = 'block' + document.getElementById('logout').style.display = 'block' sdk.ping().then(pong => console.log({pong})) }) @@ -115,6 +119,12 @@

Hello, world!

}) } + function go_logout() { + auth.logout() + reset() + signinbtn.style.display = 'inline-block' + } + function go_payload() { /** * xumm-oauth2-pkce package returns `sdk` property, @@ -122,7 +132,6 @@

Hello, world!

* Xumm SDK methods, docs: * https://www.npmjs.com/package/xumm-sdk **/ - var payload = { txjson: { TransactionType: 'Payment', diff --git a/src/index.ts b/src/index.ts index 74e1e6b..903b352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,11 @@ const log = Debug("xummpkce"); log("Xumm OAuth2 PKCE Authorization Code Flow lib."); +interface XummPkceOptions { + redirectUrl: string; + rememberJwt: boolean; + storage: Storage; +} interface ResolvedFlow { sdk: XummSdkJwt; jwt: string; @@ -51,34 +56,102 @@ export declare interface XummPkce { export class XummPkce extends EventEmitter { private pkce: PKCE; + private options: XummPkceOptions; private popup: Window | null = null; private jwt?: string; + private resolved = false; private resolvePromise?: (result: ResolvedFlow) => void; private rejectPromise?: (error: Error) => void; private promise?: Promise; + private autoResolvedFlow?: ResolvedFlow; private mobileRedirectFlow: boolean = false; private urlParams?: URLSearchParams; - constructor(xummApiKey: string, redirectUrl?: string) { + constructor( + xummApiKey: string, + optionsOrRedirectUrl?: string | XummPkceOptions + ) { super(); + this.options = { + redirectUrl: document.location.href, + rememberJwt: true, + storage: localStorage, + }; + + /** + * Apply options + */ + if (typeof optionsOrRedirectUrl === "string") { + this.options.redirectUrl = optionsOrRedirectUrl; + } else if ( + typeof optionsOrRedirectUrl === "object" && + optionsOrRedirectUrl + ) { + if (typeof this.options.redirectUrl === "string") { + this.options.redirectUrl = this.options.redirectUrl; + } + if (typeof this.options.rememberJwt === "boolean") { + this.options.rememberJwt = this.options.rememberJwt; + } + if (typeof this.options.storage === "object") { + this.options.storage = this.options.storage; + } + } + + /** + * Construct + */ this.pkce = new PKCE({ client_id: xummApiKey, - redirect_uri: redirectUrl || document.location.href, + redirect_uri: this.options.redirectUrl, authorization_endpoint: "https://oauth2.xumm.app/auth", token_endpoint: "https://oauth2.xumm.app/token", requested_scopes: "XummPkce", - storage: localStorage, + storage: this.options.storage, }); + /** + * Check if there is already a valid JWT to be used + */ + if (this.options.rememberJwt) { + log("Remember JWT"); + try { + const existingJwt = JSON.parse( + this.options.storage?.getItem("XummPkceJwt") || "{}" + ); + + if (existingJwt?.jwt && typeof existingJwt.jwt === "string") { + const sdk = new XummSdkJwt(existingJwt.jwt); + sdk.ping().then((pong) => { + /** + * Pretend mobile so no window.open is triggered + */ + if (pong?.jwtData?.sub) { + // Yay, user still signed in, JWT still valid! + this.autoResolvedFlow = Object.assign(existingJwt, { sdk }); + this.emit("result"); + } else { + this.logout(); + } + }); + } + } catch (e) { + // Do nothing + } + } + window.addEventListener( "message", (event) => { log("Received Event from ", event.origin); - if (String(event?.data || '').slice(0, 1) === "{" && String(event?.data || '').slice(-1) === "}") { + if ( + String(event?.data || "").slice(0, 1) === "{" && + String(event?.data || "").slice(-1) === "}" + ) { log("Got PostMessage with JSON"); if ( event.origin === "https://xumm.app" || @@ -103,6 +176,14 @@ export class XummPkce extends EventEmitter { "Payload resolved, mostmessage containing options containing redirect URL: ", postMessage ); + + /** + * Beat the 750ms timing for the window close as the exchange + * may still take a whil (async HTTP call). We don't know YET + * if we resolved successfully but we sure did resolve. + */ + this.resolved = true; + this.pkce .exchangeForAccessToken(postMessage.options.full_redirect_uri) .then((resp) => { @@ -125,6 +206,18 @@ export class XummPkce extends EventEmitter { .then((r) => r.json()) .then((me) => { if (this.resolvePromise) { + if (this.options.rememberJwt) { + log("Remembering JWT"); + try { + this.options.storage?.setItem( + "XummPkceJwt", + JSON.stringify({ jwt: resp.access_token, me }) + ); + } catch (e) { + log("Could not persist JWT to local storage", e); + } + } + this.resolvePromise({ jwt: resp.access_token, sdk: new XummSdkJwt(resp.access_token), @@ -151,8 +244,18 @@ export class XummPkce extends EventEmitter { ) ); } + } else if ( + postMessage?.source === "xumm_sign_request_popup_closed" + ) { + log("Popup closed, wait 750ms"); + // Wait, maybe the real reason comes in later (e.g. explicitly rejected) + setTimeout(() => { + if (!this.resolved && this.rejectPromise) { + this.rejectPromise(new Error("Sign In window closed")); + } + }, 750); } else { - log("Unexpected message, skipping"); + log("Unexpected message, skipping", postMessage?.source); } } catch (e: unknown) { log("Error parsing message", (e as Error)?.message || e); @@ -217,7 +320,7 @@ export class XummPkce extends EventEmitter { } public async authorize() { - if (!this.mobileRedirectFlow) { + if (!this.mobileRedirectFlow && !this.autoResolvedFlow) { const url = this.authorizeUrl(); const popup = window.open( url, @@ -227,17 +330,45 @@ export class XummPkce extends EventEmitter { ); this.popup = popup; + log("Popup opened...", url); } - this.promise = new Promise((resolve, reject) => { - this.resolvePromise = resolve; - this.rejectPromise = reject; - }); + this.resolved = false; + + if (this.autoResolvedFlow) { + this.resolved = true; + this.promise = Promise.resolve(this.autoResolvedFlow); + this.rejectPromise = this.resolvePromise = () => {}; + log("Auto resolved"); + } else { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = (_) => { + this.resolved = true; + log("Xumm Sign in RESOLVED"); + return resolve(_); + }; + this.rejectPromise = (_) => { + this.resolved = true; + log("Xumm Sign in REJECTED"); + return reject(_); + }; + }); + } return this.promise; } + public logout() { + try { + this.autoResolvedFlow = undefined; + this.options.storage?.removeItem("XummPkceJwt"); + } catch (e) { + // Nothing to do + } + return; + } + public getPopup() { return this?.popup; }