diff --git a/src/credentials_provider/http_request.ts b/src/credentials_provider/http_request.ts index 35571a19e..475e706cf 100644 --- a/src/credentials_provider/http_request.ts +++ b/src/credentials_provider/http_request.ts @@ -41,11 +41,14 @@ export async function fetchWithCredentials( credentials: Credentials, requestInit: RequestInit, ) => RequestInit, - errorHandler: (httpError: HttpError, credentials: Credentials) => "refresh", + errorHandler: ( + httpError: HttpError, + credentials: Credentials, + ) => "refresh" | Promise<"refresh">, cancellationToken: CancellationToken = uncancelableToken, ): Promise { let credentials: CredentialsWithGeneration | undefined; - for (let credentialsAttempt = 0; ; ) { + credentialsLoop: for (let credentialsAttempt = 0; ; ) { throwIfCanceled(cancellationToken); if (credentialsAttempt > 1) { // Don't delay on the first attempt, and also don't delay on the second attempt, since if the @@ -65,7 +68,9 @@ export async function fetchWithCredentials( ); } catch (error) { if (error instanceof HttpError) { - if (errorHandler(error, credentials.credentials) === "refresh") { + if ( + (await errorHandler(error, credentials.credentials)) === "refresh" + ) { if (++credentialsAttempt === maxCredentialsAttempts) throw error; continue; } diff --git a/src/credentials_provider/index.ts b/src/credentials_provider/index.ts index 8a189df15..3e471de33 100644 --- a/src/credentials_provider/index.ts +++ b/src/credentials_provider/index.ts @@ -24,6 +24,8 @@ import { } from "#/util/cancellation"; import { Owned, RefCounted } from "#/util/disposable"; import { StringMemoize } from "#/util/memoize"; +import { HttpError } from "#/util/http_request"; +import { OAuth2Credentials } from "#/credentials_provider/oauth2"; /** * Wraps an arbitrary JSON credentials object with a generation number. @@ -46,6 +48,26 @@ export abstract class CredentialsProvider extends RefCounted { invalidCredentials?: CredentialsWithGeneration, cancellationToken?: CancellationToken, ) => Promise>; + + errorHandler? = async ( + error: HttpError, + credentials: OAuth2Credentials, + ): Promise<"refresh"> => { + const { status } = error; + if (status === 401) { + // 401: Authorization needed. OAuth2 token may have expired. + return "refresh"; + } else if (status === 403 && !credentials.accessToken) { + // Anonymous access denied. Request credentials. + return "refresh"; + } + if (error instanceof Error && credentials.email !== undefined) { + error.message += ` (Using credentials for ${JSON.stringify( + credentials.email, + )})`; + } + throw error; + }; } export function makeCachedCredentialsGetter( diff --git a/src/credentials_provider/oauth2.ts b/src/credentials_provider/oauth2.ts index 50de906f1..7c66b1380 100644 --- a/src/credentials_provider/oauth2.ts +++ b/src/credentials_provider/oauth2.ts @@ -57,23 +57,7 @@ export function fetchWithOAuth2Credentials( ); return { ...init, headers }; }, - (error, credentials) => { - const { status } = error; - if (status === 401) { - // 401: Authorization needed. OAuth2 token may have expired. - return "refresh"; - } - if (status === 403 && !credentials.accessToken) { - // Anonymous access denied. Request credentials. - return "refresh"; - } - if (error instanceof Error && credentials.email !== undefined) { - error.message += ` (Using credentials for ${JSON.stringify( - credentials.email, - )})`; - } - throw error; - }, + credentialsProvider.errorHandler!, cancellationToken, ); } diff --git a/src/datasource/middleauth/credentials_provider.ts b/src/datasource/middleauth/credentials_provider.ts index b7a94b0a9..f476f3b76 100644 --- a/src/datasource/middleauth/credentials_provider.ts +++ b/src/datasource/middleauth/credentials_provider.ts @@ -27,6 +27,8 @@ import { verifyString, verifyStringArray, } from "#/util/json"; +import { HttpError } from "#/util/http_request"; +import { OAuth2Credentials } from "#/credentials_provider/oauth2"; export type MiddleAuthToken = { tokenType: string; @@ -49,79 +51,49 @@ function openPopupCenter(url: string, width: number, height: number) { ); } -async function waitForLogin(serverUrl: string): Promise { +async function waitForRemoteFlow( + url: string, + startMessage: string, + startAction: string, + retryMessage: string, + closedMessage: string, +): Promise { const status = new StatusMessage(/*delay=*/ false); - const res: Promise = new Promise((f) => { - function writeLoginStatus(message: string, buttonMessage: string) { + function writeStatus(message: string, buttonMessage: string) { status.element.textContent = message + " "; const button = document.createElement("button"); button.textContent = buttonMessage; status.element.appendChild(button); button.addEventListener("click", () => { - writeLoginStatus( - `Waiting for login to middleauth server ${serverUrl}...`, - "Retry", - ); - - const auth_popup = openPopupCenter( - `${serverUrl}/api/v1/authorize`, - 400, - 650, - ); - - const closeAuthPopup = () => { - auth_popup?.close(); + writeStatus(retryMessage, "Retry"); + const popup = openPopupCenter(url, 400, 650); + const closePopup = () => { + popup?.close(); }; - - window.addEventListener("beforeunload", closeAuthPopup); + window.addEventListener("beforeunload", closePopup); const checkClosed = setInterval(() => { - if (auth_popup?.closed) { + if (popup?.closed) { clearInterval(checkClosed); - writeLoginStatus( - `Login window closed for middleauth server ${serverUrl}.`, - "Retry", - ); + writeStatus(closedMessage, "Retry"); } }, 1000); - const tokenListener = async (ev: MessageEvent) => { - if (ev.source === auth_popup) { + const messageListener = async (ev: MessageEvent) => { + if (ev.source === popup) { clearInterval(checkClosed); - window.removeEventListener("message", tokenListener); - window.removeEventListener("beforeunload", closeAuthPopup); - closeAuthPopup(); - - verifyObject(ev.data); - const accessToken = verifyObjectProperty( - ev.data, - "token", - verifyString, - ); - const appUrls = verifyObjectProperty( - ev.data, - "app_urls", - verifyStringArray, - ); - - const token: MiddleAuthToken = { - tokenType: "Bearer", - accessToken, - url: serverUrl, - appUrls, - }; - f(token); + window.removeEventListener("message", messageListener); + window.removeEventListener("beforeunload", closePopup); + closePopup(); + f(ev.data); } }; - - window.addEventListener("message", tokenListener); + window.addEventListener("message", messageListener); }); } - - writeLoginStatus(`middleauth server ${serverUrl} login required.`, "Login"); + writeStatus(startMessage, startAction); }); - try { return await res; } finally { @@ -129,6 +101,38 @@ async function waitForLogin(serverUrl: string): Promise { } } +async function waitForLogin(serverUrl: string): Promise { + console.log("wait for login"); + const data = await waitForRemoteFlow( + `${serverUrl}/api/v1/authorize`, + `middleauth server ${serverUrl} login required.`, + "Login", + `Waiting for login to middleauth server ${serverUrl}...`, + `Login window closed for middleauth server ${serverUrl}.`, + ); + verifyObject(data); + const accessToken = verifyObjectProperty(data, "token", verifyString); + const appUrls = verifyObjectProperty(data, "app_urls", verifyStringArray); + const token: MiddleAuthToken = { + tokenType: "Bearer", + accessToken, + url: serverUrl, + appUrls, + }; + return token; +} + +async function showTosForm(url: string, tosName: string) { + const data = await waitForRemoteFlow( + url, + `Before you can access ${tosName}, you need to accept its Terms of Service.`, + "Open", + `Waiting for Terms of Service agreement...`, + `Terms of Service closed for ${tosName}.`, + ); + return data === "success"; +} + const LOCAL_STORAGE_AUTH_KEY = "auth_token_v2"; function getAuthTokenFromLocalStorage(authURL: string) { @@ -154,17 +158,14 @@ export class MiddleAuthCredentialsProvider extends CredentialsProvider { let token = undefined; - if (!this.alreadyTriedLocalStorage) { this.alreadyTriedLocalStorage = true; token = getAuthTokenFromLocalStorage(this.serverUrl); } - if (!token) { token = await waitForLogin(this.serverUrl); saveAuthTokenToLocalStorage(this.serverUrl, token); } - return token; }); } @@ -181,6 +182,7 @@ export class UnverifiedApp extends Error { export class MiddleAuthAppCredentialsProvider extends CredentialsProvider { private credentials: CredentialsWithGeneration | undefined = undefined; + agreedToTos = false; constructor( private serverUrl: string, @@ -190,6 +192,10 @@ export class MiddleAuthAppCredentialsProvider extends CredentialsProvider { + if (this.credentials && this.agreedToTos) { + return this.credentials.credentials; + } + this.agreedToTos = false; const authInfo = await fetch(`${this.serverUrl}/auth_info`).then((res) => res.json(), ); @@ -197,9 +203,7 @@ export class MiddleAuthAppCredentialsProvider extends CredentialsProvider => { + console.log("ma handle error", error); + const { status } = error; + if (status === 401) { + // 401: Authorization needed. OAuth2 token may have expired. + return "refresh"; + } else if (status === 403) { + // Anonymous access denied. Request credentials. + const { response } = error; + if (response) { + const { headers } = response; + const contentType = headers.get("content-type"); + if (contentType === "application/json") { + const json = await response.json(); + if (json.error && json.error === "missing_tos") { + const url = new URL(json.data.tos_form_url); + url.searchParams.set("client", "ng"); + const success = await showTosForm( + url.toString(), + json.data.tos_name, + ); + if (success) { + this.agreedToTos = true; + return "refresh"; + } + } + } + } + if (!credentials.accessToken) { + return "refresh"; + } + } + throw error; + }; }