diff --git a/src/AuthorizationCodeFlow.ts b/src/AuthorizationCodeFlow.ts index ab6854a..06fcf15 100644 --- a/src/AuthorizationCodeFlow.ts +++ b/src/AuthorizationCodeFlow.ts @@ -187,9 +187,28 @@ export class AuthorizationCodeFlow extends HTMLElement { this.#cancelCodeRequest = cancelCodeRequest const onMessage = (message: MessageEvent) => { + // Only the authorization popup may end the flow. Anything else on this + // window (other components, extensions, a hostile frame) must neither + // resolve the flow early nor spoof an authorization response. + if (message.source !== this.#authorizationWindow) { + return + } + + this.ownerDocument.defaultView?.removeEventListener("message", onMessage) signal.removeEventListener("abort", onAbort) this.#switchModal.close() - this.#authorizationWindow?.close() + + // When the server answered a silent (`prompt=none`) attempt with a "user + // interaction needed" error, the caller is about to retry interactively. + // Keep the popup open: the retry's `open()` then NAVIGATES this named + // window, which browsers allow without user activation. Closing it here + // would make the retry create a new window — popup blockers stop that + // (the original click's activation is already consumed), stranding the + // user in the "open new window" dialog. + if (!needsInteraction(message.data)) { + this.#authorizationWindow?.close() + } + respondWithCode(message.data) } @@ -202,7 +221,9 @@ export class AuthorizationCodeFlow extends HTMLElement { } signal.addEventListener("abort", onAbort, onlyOnce) - this.ownerDocument.defaultView?.addEventListener("message", onMessage, onlyOnce) + // Not `once`: the listener filters by source, so it must survive unrelated + // messages. It removes itself when the popup answers (or on abort). + this.ownerDocument.defaultView?.addEventListener("message", onMessage) this.#openAuthorizationWindow() @@ -247,3 +268,23 @@ export class AuthorizationCodeFlow extends HTMLElement { this.#cancelCodeRequest?.call(undefined, new CodeRequestCancelledError(this.#authorizationUri!)) } } + +/** + * Whether the authorization response is one of the OIDC "the user must interact" + * errors — exactly the errors token providers retry interactively right away. + * Anything else (a code, or a terminal error such as `access_denied`) ends the flow. + */ +function needsInteraction(authorizationResponse: unknown): boolean { + if (typeof authorizationResponse !== "string") { + return false + } + + let error + try { + error = new URL(authorizationResponse).searchParams.get("error") + } catch { + return false + } + + return error === "login_required" || error === "interaction_required" || error === "consent_required" +}