diff --git a/package.json b/package.json index 9a9fe3a88..1511d5e5e 100644 --- a/package.json +++ b/package.json @@ -197,6 +197,13 @@ "neuroglancer/datasource/dvid:disabled": "./src/datasource/dvid/register_credentials_provider.ts", "default": "./src/datasource/dvid/register_credentials_provider.ts" }, + "#datasource/globus/register_credentials_provider": { + "neuroglancer/python": "./src/util/false.ts", + "neuroglancer/datasource/globus:enabled": "./src/datasource/globus/register_credentials_provider.ts", + "neuroglancer/datasource:none_by_default": "./src/util/false.ts", + "neuroglancer/datasource/globus:disabled": "./src/datasource/globus/register_credentials_provider.ts", + "default": "./src/datasource/globus/register_credentials_provider.ts" + }, "#datasource/graphene/backend": { "neuroglancer/datasource/graphene:enabled": "./src/datasource/graphene/backend.ts", "neuroglancer/datasource:none_by_default": "./src/util/false.ts", diff --git a/src/datasource/enabled_frontend_modules.ts b/src/datasource/enabled_frontend_modules.ts index 96b934b7b..e4be0f469 100644 --- a/src/datasource/enabled_frontend_modules.ts +++ b/src/datasource/enabled_frontend_modules.ts @@ -6,6 +6,7 @@ import "#datasource/brainmaps/register_credentials_provider"; import "#datasource/deepzoom/register_default"; import "#datasource/dvid/register_default"; import "#datasource/dvid/register_credentials_provider"; +import "#datasource/globus/register_credentials_provider"; import "#datasource/graphene/register_default"; import "#datasource/middleauth/register_credentials_provider"; import "#datasource/n5/register_default"; diff --git a/src/datasource/globus/README.md b/src/datasource/globus/README.md new file mode 100644 index 000000000..61e28c013 --- /dev/null +++ b/src/datasource/globus/README.md @@ -0,0 +1,19 @@ +Provides access to resources accessible via Globus. + +--- + +The Globus datasource provides access to resources stored on storage systems configured with Globus Connect Server that support [HTTPS access](https://docs.globus.org/globus-connect-server/v5.4/https-access-collections/). + +[Globus Auth](https://docs.globus.org/api/auth/) is used as the authorization mechanism for accessing resources. + +When invoked, the `globus+https://` protocol will: + +- Require the user to provide the UUID of the Globus Collection the asset is stored on. + - The UUID is required to create the proper OAuth2 `scope` to access the asset. + - When authorization succeeds, the provided UUID will be stored in `localStorage` to avoid prompting the user for the UUID on subsequent requests. +- Initiate an OAuth2 flow to Globus Auth, using PKCE, to obtain an access token. +- Store the access token in `localStorage` for subsequent requests to the same resource server (Globus Connect Server instance). + +## Configuration + +A default Globus application Client ID (`GLOBUS_CLIENT_ID`) is provided by the Webpack configuration. The provided client will allow usage on `localhost`, but will not work on other domains. To use the Globus datasource on a different domain, you will need to [register your own Globus application](https://docs.globus.org/api/auth/developer-guide/#register-app), and provide the Client ID in the `GLOBUS_CLIENT_ID` environment variable. diff --git a/src/datasource/globus/credentials_provider.ts b/src/datasource/globus/credentials_provider.ts new file mode 100644 index 000000000..5f5ee300a --- /dev/null +++ b/src/datasource/globus/credentials_provider.ts @@ -0,0 +1,246 @@ +import { + CredentialsProvider, + makeCredentialsGetter, +} from "#src/credentials_provider/index.js"; +import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; +import { StatusMessage } from "#src/status.js"; +import { uncancelableToken } from "#src/util/cancellation.js"; +import { HttpError } from "#src/util/http_request.js"; +import { + generateCodeChallenge, + generateCodeVerifier, + waitForPKCEResponseMessage, +} from "#src/util/pkce.js"; +import { getRandomHexString } from "#src/util/random.js"; + +const GLOBUS_AUTH_HOST = "https://auth.globus.org"; +const REDIRECT_URI = new URL("./globus_oauth2_redirect.html", import.meta.url) + .href; + +function getRequiredScopes(endpoint: string) { + return `https://auth.globus.org/scopes/${endpoint}/https`; +} + +function getGlobusAuthorizeURL({ + endpoint, + clientId, + code_challenge, + state, +}: { + endpoint: string; + clientId: string; + code_challenge: string; + state: string; +}) { + const url = new URL("/v2/oauth2/authorize", GLOBUS_AUTH_HOST); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("code_challenge", code_challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("scope", getRequiredScopes(endpoint)); + return url.toString(); +} + +function getGlobusTokenURL({ + clientId, + code, + code_verifier, +}: { + code: string; + clientId: string; + code_verifier: string; +}) { + const url = new URL("/v2/oauth2/token", GLOBUS_AUTH_HOST); + url.searchParams.set("grant_type", "authorization_code"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("code_verifier", code_verifier); + url.searchParams.set("code", code); + return url.toString(); +} + +type GlobusLocalStorage = { + authorizations?: { + [resourceServer: string]: OAuth2Credentials; + }; + /** + * Globus Connect Server domain mappings. + * Currently, there is no way to progrmatically determine the UUID of a GCS + * endpoint from their domain name, so a user will need to provide a UUID + * when attempting to access a file from a GCS endpoint. + */ + domainMappings?: { + [domain: string]: string; + }; +}; + +function getStorage() { + return JSON.parse( + localStorage.getItem("globus") || "{}", + ) as GlobusLocalStorage; +} + +async function waitForAuth( + clientId: string, + gcsHttpsHost: string, +): Promise { + const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true); + + const res: Promise = new Promise((resolve) => { + const frag = document.createDocumentFragment(); + + const title = document.createElement("h1"); + title.textContent = "Authenticate with Globus"; + title.style.fontSize = "1.5em"; + + frag.appendChild(title); + + let identifier = getStorage().domainMappings?.[gcsHttpsHost]; + + const link = document.createElement("button"); + link.textContent = "Log in to Globus"; + link.disabled = true; + + if (!identifier) { + const label = document.createElement("label"); + label.textContent = "Globus Collection UUID"; + label.style.display = "block"; + label.style.margin = ".5em 0"; + frag.appendChild(label); + const endpoint = document.createElement("input"); + endpoint.style.width = "100%"; + endpoint.style.margin = ".5em 0"; + endpoint.type = "text"; + endpoint.placeholder = "a17d7fac-ce06-4ede-8318-ad8dc98edd69"; + endpoint.addEventListener("input", async (e) => { + identifier = (e.target as HTMLInputElement).value; + link.disabled = !identifier; + }); + frag.appendChild(endpoint); + } else { + link.disabled = false; + } + + link.addEventListener("click", async (event) => { + event.preventDefault(); + if (!identifier) { + status.setText("You must provide a Globus Collection UUID."); + return; + } + const verifier = generateCodeVerifier(); + const state = getRandomHexString(); + const challenge = await generateCodeChallenge(verifier); + const url = getGlobusAuthorizeURL({ + clientId, + endpoint: identifier, + code_challenge: challenge, + state, + }); + + const source = window.open(url, "_blank"); + if (!source) { + status.setText("Failed to open login window."); + return; + } + let rawToken: + | { + access_token: string; + token_type: string; + resource_server: string; + } + | undefined; + const token = await waitForPKCEResponseMessage({ + source, + state, + cancellationToken: uncancelableToken, + tokenExchangeCallback: async (code) => { + const response = await fetch( + getGlobusTokenURL({ clientId, code, code_verifier: verifier }), + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + if (!response.ok) { + throw new Error("Failed to exchange code for token"); + } + rawToken = await response.json(); + if (!rawToken?.access_token || !rawToken?.token_type) { + throw new Error("Invalid token response"); + } + return { + accessToken: rawToken.access_token, + tokenType: rawToken.token_type, + }; + }, + }); + + if (!rawToken) { + status.setText("Failed to obtain token."); + return; + } + + /** + * We were able to obtain a token, store it in local storage along with + * the domain mapping since we know it is correct. + */ + const storage = getStorage(); + storage.authorizations = { + ...storage.authorizations, + [rawToken.resource_server]: token, + }; + storage.domainMappings = { + ...storage.domainMappings, + [gcsHttpsHost]: rawToken.resource_server, + }; + + localStorage.setItem("globus", JSON.stringify(storage)); + resolve(token); + }); + frag.appendChild(link); + status.element.appendChild(frag); + }); + + try { + return await res; + } finally { + status.dispose(); + } +} + +export class GlobusCredentialsProvider extends CredentialsProvider { + constructor( + public clientId: string, + public gcsHttpsHost: string, + ) { + super(); + } + get = makeCredentialsGetter(async () => { + const resourceServer = getStorage().domainMappings?.[this.gcsHttpsHost]; + const token = resourceServer + ? getStorage().authorizations?.[resourceServer] + : undefined; + if (!token) { + return await waitForAuth(this.clientId, this.gcsHttpsHost); + } + const response = await fetch(`${this.gcsHttpsHost}`, { + method: "HEAD", + headers: { + "X-Requested-With": "XMLHttpRequest", + Authorization: `${token?.tokenType} ${token?.accessToken}`, + }, + }); + switch (response.status) { + case 200: + return token; + case 401: + return await waitForAuth(this.clientId, this.gcsHttpsHost); + default: + throw HttpError.fromResponse(response); + } + }); +} diff --git a/src/datasource/globus/globus_oauth2_redirect.html b/src/datasource/globus/globus_oauth2_redirect.html new file mode 100644 index 000000000..efe7007ae --- /dev/null +++ b/src/datasource/globus/globus_oauth2_redirect.html @@ -0,0 +1,21 @@ + + + + Globus OAuth Redirect + + + +

Globus authentication successful.

+

+ + diff --git a/src/datasource/globus/register_credentials_provider.ts b/src/datasource/globus/register_credentials_provider.ts new file mode 100644 index 000000000..b9cbd036a --- /dev/null +++ b/src/datasource/globus/register_credentials_provider.ts @@ -0,0 +1,15 @@ +import { defaultCredentialsManager } from "#src/credentials_provider/default_manager.js"; +import { GlobusCredentialsProvider } from "#src/datasource/globus/credentials_provider.js"; + +export declare const GLOBUS_CLIENT_ID: string | undefined; + +export function isGlobusEnabled() { + return typeof GLOBUS_CLIENT_ID !== "undefined"; +} + +if (typeof GLOBUS_CLIENT_ID !== "undefined") { + defaultCredentialsManager.register( + "globus", + (serverUrl) => new GlobusCredentialsProvider(GLOBUS_CLIENT_ID, serverUrl), + ); +} diff --git a/src/util/http_path_completion.ts b/src/util/http_path_completion.ts index 93743b759..e71f95cdf 100644 --- a/src/util/http_path_completion.ts +++ b/src/util/http_path_completion.ts @@ -15,6 +15,7 @@ */ import type { CredentialsManager } from "#src/credentials_provider/index.js"; +import { isGlobusEnabled } from "#src/datasource/globus/register_credentials_provider.js"; import type { CancellationToken } from "#src/util/cancellation.js"; import type { BasicCompletionResult, @@ -121,6 +122,13 @@ const specialProtocolEmptyCompletions: CompletionWithDescription[] = [ { value: "http://" }, ]; +if (isGlobusEnabled()) { + specialProtocolEmptyCompletions.push({ + value: "globus+https://", + description: "Globus-sourced data authenticated via Globus Auth", + }); +} + export async function completeHttpPath( credentialsManager: CredentialsManager, url: string, diff --git a/src/util/pkce.ts b/src/util/pkce.ts new file mode 100644 index 000000000..4fcb1f705 --- /dev/null +++ b/src/util/pkce.ts @@ -0,0 +1,125 @@ +import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; +import { type CancellationToken, CANCELED } from "#src/util/cancellation.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { + verifyObject, + verifyObjectProperty, + verifyString, +} from "#src/util/json.js"; + +/** + * Utilities related to Proof Key for Code Exchange (PKCE). + * @see https://oauth.net/2/pkce/ + */ + +/** + * Character set for generating random alpha-numeric strings. + */ +const CHARSET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +/** + * Character set allowed to be used in the PKCE `code_verifier` + * @see https://www.rfc-editor.org/rfc/rfc7636#section-4.1 + */ +const PKCE_SAFE_CHARSET = `${CHARSET}-._~`; + +/** + * Create a Code Verifier for PKCE + * @see https://www.rfc-editor.org/rfc/rfc7636#section-4.1 + */ +export function generateCodeVerifier(size = 43) { + return Array.from(crypto.getRandomValues(new Uint8Array(size))) + .map((v) => PKCE_SAFE_CHARSET[v % PKCE_SAFE_CHARSET.length]) + .join(""); +} + +/** + * Base64 URL encode a string. + * @see https://www.oauth.com/oauth2-servers/pkce/authorization-request/ + */ +const encode = (value: string) => + btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +async function sha256(input: string) { + const hashBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(input), + ); + return String.fromCharCode(...new Uint8Array(hashBuffer)); +} + +/** + * Create a Code Challenge from a provided Code Verifier (assumes S256 `code_challenge_method`). + * @see https://www.rfc-editor.org/rfc/rfc7636#section-4.2 + */ +export async function generateCodeChallenge(verifier: string) { + const hashed = await sha256(verifier); + return encode(hashed); +} + +/** + * Similar to `#src/util/google_oauth2.ts` `waitForAuthResponseMessage`, but incorporates PKCE. + */ +export async function waitForPKCEResponseMessage({ + source, + state, + cancellationToken, + tokenExchangeCallback, +}: { + source: Window; + state: string; + cancellationToken: CancellationToken; + /** + * Callback to exchange the received code for OAuth2 credentials. + * This will be called when a valid message (`code` and origin match) is received from the `source`. + */ + tokenExchangeCallback: (code: string) => Promise; +}): Promise { + const context = new RefCounted(); + try { + return await new Promise((resolve, reject) => { + context.registerDisposer(cancellationToken.add(() => reject(CANCELED))); + context.registerEventListener( + window, + "message", + (event: MessageEvent) => { + if (event.origin !== location.origin) { + return; + } + + if (event.source !== source) return; + + try { + const obj = verifyObject(event.data); + const receivedState = verifyObjectProperty( + obj, + "state", + verifyString, + ); + if (receivedState !== state) { + throw new Error("invalid state"); + } + const receivedCode = verifyObjectProperty( + obj, + "code", + verifyString, + ); + if (receivedCode === undefined) { + throw new Error("missing code"); + } + tokenExchangeCallback(receivedCode).then(resolve); + } catch (parseError) { + reject( + new Error( + `Received unexpected authentication response: ${parseError.message}`, + ), + ); + console.error("Response received: ", event.data); + } + }, + ); + }); + } finally { + context.dispose(); + } +} diff --git a/src/util/special_protocol_request.ts b/src/util/special_protocol_request.ts index f79590b78..e4db49f12 100644 --- a/src/util/special_protocol_request.ts +++ b/src/util/special_protocol_request.ts @@ -109,6 +109,14 @@ export function parseSpecialUrl( ), url: "gs+xml:/" + u.path, }; + case "globus+https": + return { + credentialsProvider: credentialsManager.getCredentialsProvider( + "globus", + `https://${u.host}`, + ), + url: `https://${u.host}${u.path}`, + }; case "middleauth+https": url = url.substr("middleauth+".length); return { diff --git a/webpack.config.js b/webpack.config.js index 7047d01fd..dd9fc9ae3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -55,9 +55,9 @@ export default (env, args) => { type: "asset/source", }, // Needed for .html assets used for auth redirect pages for the - // brainmaps and bossDB data sources. + // brainmaps, globus, and bossDB data sources. { - test: /(bossauth|google_oauth2_redirect)\.html$/, + test: /(bossauth|google_oauth2_redirect|globus_oauth2_redirect)\.html$/, type: "asset/resource", generator: { // Filename must be preserved since exact redirect URLs must be allowlisted. @@ -145,6 +145,12 @@ export default (env, args) => { // NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP: true // NEUROGLANCER_GOOGLE_TAG_MANAGER: JSON.stringify('GTM-XXXXXX'), + /** + * To deploy to a different origin, you will need to generate your + * own Client ID from Globus and substitute it in. + * @see https://docs.globus.org/api/auth/developer-guide/#developing-apps + */ + GLOBUS_CLIENT_ID: JSON.stringify("f3c5dd86-8c8e-4393-8f46-3bfa32bfcd73"), }, }; return env.NEUROGLANCER_CLI