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 Connect Server-sourced assets as a datasource (via HTTPS) #675

Draft
wants to merge 3 commits 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
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@
"neuroglancer/datasource/dvid:disabled": "./src/util/false.ts",
"default": "./src/datasource/dvid/register_credentials_provider.ts"
},
"#datasource/globus/register_default": {
"neuroglancer/datasource/globus:enabled": "./src/datasource/globus/register_default.ts",
"neuroglancer/datasource:none_by_default": "./src/util/false.ts",
"neuroglancer/datasource/globus:disabled": "./src/util/false.ts",
"default": "./src/datasource/globus/register_default.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/util/false.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
10 changes: 8 additions & 2 deletions rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,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.
Expand Down Expand Up @@ -116,6 +116,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"),
},
watchOptions: {
ignored: /node_modules/,
Expand Down
2 changes: 2 additions & 0 deletions src/datasource/enabled_frontend_modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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_default";
import "#datasource/globus/register_credentials_provider";
import "#datasource/graphene/register_default";
import "#datasource/n5/register_default";
import "#datasource/nggraph/register_default";
Expand Down
17 changes: 17 additions & 0 deletions src/datasource/globus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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:

- When unauthententicated: Make a request to the Globus Connect Server HTTPS domain to determine required scopes.
- 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.
239 changes: 239 additions & 0 deletions src/datasource/globus/credentials_provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
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 { 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 getGlobusAuthorizeURL({
scope,
clientId,
code_challenge,
state,
}: {
scope: 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", scope.join(" "));
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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please refactor this to use newly added utilities in src/credentials_provider/interactive_credentials_provider.ts

clientId: string,
globusConnectServerDomain: string,
): Promise<OAuth2Credentials> {
const status = new StatusMessage(false, true);

const res: Promise<OAuth2Credentials> = new Promise((resolve) => {
const frag = document.createDocumentFragment();
const wrapper = document.createElement("div");
wrapper.style.display = "flex";
wrapper.style.flexDirection = "column";
wrapper.style.alignItems = "center";
frag.appendChild(wrapper);

const msg = document.createElement("div");
msg.textContent = "You must log in to Globus to access this resource.";
msg.style.marginBottom = ".5em";
wrapper.appendChild(msg);

const link = document.createElement("button");
link.textContent = "Log in to Globus";

link.addEventListener("click", async (event) => {
event.preventDefault();
/**
* We make a request to the Globus Connect Server domain **even though we _know_ we're
* unauthorized** to get the required consents for the resource.
*/
const authorizationIntrospectionRequest = await fetch(
globusConnectServerDomain,
{
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this header being included? it will result in an additional preflight OPTIONS request.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header is used to signal "Programmatic Access" (https://docs.globus.org/globus-connect-server/v5.4/https-access-collections/#programmatic_access) which ensures an Content-Type: application/json response. I think this is managed as a separate header than just standard Accept because the underlying asset is responsible for content negotiation when served over HTTPS.

I can add a comment here that explains this with a reference to the documentation.

},
},
);

const { authorization_parameters } =
await authorizationIntrospectionRequest.json();

const verifier = generateCodeVerifier();
const state = getRandomHexString();
const challenge = await generateCodeChallenge(verifier);
const url = getGlobusAuthorizeURL({
clientId,
scope: authorization_parameters.required_scopes,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned that there may be a security vulnerability here --- suppose the user has previously accessed:

globus+https://good-host.com/... which has UUID XXXX

and granted access.

Then the user gets directed to visit a Neuroglancer link that specifies a datasource of globus+https://bad-host.com/... bad-host.com maliciously reports that it has the same UUID of XXXX as good-host.com.

What will happen in that case? Will the user have to grant permission again or will it be assumed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, it does seem like a plausible vector. In the updated processing a server that spoofs the required_scopes of good-host.com would result in tokens for the good-host.com being sent as Authorization headers to bad-host.com.

As a way to validate the required_scope response, it seems plausible to do a reverse lookup of the domain using the the scopes in the response, but this might require additional initial consent – I'm going to discuss this internally at Globus to see if we can come up with a more secure alternative.

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,
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,
[globusConnectServerDomain]: rawToken.resource_server,
};

localStorage.setItem("globus", JSON.stringify(storage));
resolve(token);
});
wrapper.appendChild(link);
status.element.appendChild(frag);
});

try {
return await res;
} finally {
status.dispose();
}
}

export class GlobusCredentialsProvider extends CredentialsProvider<OAuth2Credentials> {
constructor(
public clientId: string,
public assetUrl: URL,
) {
super();
}
get = makeCredentialsGetter(async () => {
const globusConnectServerDomain = this.assetUrl.origin;

const resourceServer =
getStorage().domainMappings?.[globusConnectServerDomain];
const token = resourceServer
? getStorage().authorizations?.[resourceServer]
: undefined;

if (!token) {
return await waitForAuth(this.clientId, globusConnectServerDomain);
}
const response = await fetch(this.assetUrl, {
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, globusConnectServerDomain);
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>
14 changes: 14 additions & 0 deletions src/datasource/globus/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.. _globus-datasource:

Globus
======

The Globus :ref:`data service driver<data-services>` enables Neuroglancer to
access files served by a `Globus Connect Server
<https://www.globus.org/globus-connect-server/>`__ instance over HTTPS.


URL syntax
----------

- :file:`globus+https://{globus_connect_server_https_domain}/{asset_path}`
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 { registerDefaultCredentialsProvider } 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") {
registerDefaultCredentialsProvider(
"globus",
(serverUrl) => new GlobusCredentialsProvider(GLOBUS_CLIENT_ID, serverUrl),
);
}
Loading
Loading