Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds Globus GCS-sourced assets as a datasource #675

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/datasource/enabled_frontend_modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions src/datasource/globus/README.md
Original file line number Diff line number Diff line change
@@ -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.
246 changes: 246 additions & 0 deletions src/datasource/globus/credentials_provider.ts
Original file line number Diff line number Diff line change
@@ -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<OAuth2Credentials> {
const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true);

const res: Promise<OAuth2Credentials> = 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<OAuth2Credentials> {
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);
}
});
}
21 changes: 21 additions & 0 deletions src/datasource/globus/globus_oauth2_redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<title>Globus OAuth Redirect</title>
<script>
const data = Object.fromEntries(
new URLSearchParams(location.search).entries(),
);
const target = window.opener || window.parent;
if (target === window) {
console.error("No opener/parent to receive successful oauth2 response");
} else {
target.postMessage(data, window.location.origin);
}
</script>
</head>
<body>
<p>Globus authentication successful.</p>
<p><button onclick="window.close()">Close</button></p>
</body>
</html>
15 changes: 15 additions & 0 deletions src/datasource/globus/register_credentials_provider.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
8 changes: 8 additions & 0 deletions src/util/http_path_completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading