Skip to content

Commit 5ceda12

Browse files
committed
feat: adds Globus GCS-sourced assets as a datasource
1 parent 1255beb commit 5ceda12

File tree

10 files changed

+458
-2
lines changed

10 files changed

+458
-2
lines changed

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@
197197
"neuroglancer/datasource/dvid:disabled": "./src/datasource/dvid/register_credentials_provider.ts",
198198
"default": "./src/datasource/dvid/register_credentials_provider.ts"
199199
},
200+
"#datasource/globus/register_credentials_provider": {
201+
"neuroglancer/python": "./src/util/false.ts",
202+
"neuroglancer/datasource/globus:enabled": "./src/datasource/globus/register_credentials_provider.ts",
203+
"neuroglancer/datasource:none_by_default": "./src/util/false.ts",
204+
"neuroglancer/datasource/globus:disabled": "./src/datasource/globus/register_credentials_provider.ts",
205+
"default": "./src/datasource/globus/register_credentials_provider.ts"
206+
},
200207
"#datasource/graphene/backend": {
201208
"neuroglancer/datasource/graphene:enabled": "./src/datasource/graphene/backend.ts",
202209
"neuroglancer/datasource:none_by_default": "./src/util/false.ts",

src/datasource/enabled_frontend_modules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "#datasource/brainmaps/register_credentials_provider";
66
import "#datasource/deepzoom/register_default";
77
import "#datasource/dvid/register_default";
88
import "#datasource/dvid/register_credentials_provider";
9+
import "#datasource/globus/register_credentials_provider";
910
import "#datasource/graphene/register_default";
1011
import "#datasource/middleauth/register_credentials_provider";
1112
import "#datasource/n5/register_default";

src/datasource/globus/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Provides access to resources accessible via Globus.
2+
3+
---
4+
5+
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/).
6+
7+
[Globus Auth](https://docs.globus.org/api/auth/) is used as the authorization mechanism for accessing resources.
8+
9+
When invoked, the `globus+https://` protocol will:
10+
11+
- Require the user to provide the UUID of the Globus Collection the asset is stored on.
12+
- The UUID is required to create the proper OAuth2 `scope` to access the asset.
13+
- When authorization succeeds, the provided UUID will be stored in `localStorage` to avoid prompting the user for the UUID on subsequent requests.
14+
- Initiate an OAuth2 flow to Globus Auth, using PKCE, to obtain an access token.
15+
- Store the access token in `localStorage` for subsequent requests to the same resource server (Globus Connect Server instance).
16+
17+
## Configuration
18+
19+
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.
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import {
2+
CredentialsProvider,
3+
makeCredentialsGetter,
4+
} from "#src/credentials_provider/index.js";
5+
import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js";
6+
import { StatusMessage } from "#src/status.js";
7+
import { uncancelableToken } from "#src/util/cancellation.js";
8+
import { HttpError } from "#src/util/http_request.js";
9+
import {
10+
generateCodeChallenge,
11+
generateCodeVerifier,
12+
waitForPKCEResponseMessage,
13+
} from "#src/util/pkce.js";
14+
import { getRandomHexString } from "#src/util/random.js";
15+
16+
const GLOBUS_AUTH_HOST = "https://auth.globus.org";
17+
const REDIRECT_URI = new URL("./globus_oauth2_redirect.html", import.meta.url)
18+
.href;
19+
20+
function getRequiredScopes(endpoint: string) {
21+
return `https://auth.globus.org/scopes/${endpoint}/https`;
22+
}
23+
24+
function getGlobusAuthorizeURL({
25+
endpoint,
26+
clientId,
27+
code_challenge,
28+
state,
29+
}: {
30+
endpoint: string;
31+
clientId: string;
32+
code_challenge: string;
33+
state: string;
34+
}) {
35+
const url = new URL("/v2/oauth2/authorize", GLOBUS_AUTH_HOST);
36+
url.searchParams.set("response_type", "code");
37+
url.searchParams.set("client_id", clientId);
38+
url.searchParams.set("redirect_uri", REDIRECT_URI);
39+
url.searchParams.set("code_challenge", code_challenge);
40+
url.searchParams.set("code_challenge_method", "S256");
41+
url.searchParams.set("state", state);
42+
url.searchParams.set("scope", getRequiredScopes(endpoint));
43+
return url.toString();
44+
}
45+
46+
function getGlobusTokenURL({
47+
clientId,
48+
code,
49+
code_verifier,
50+
}: {
51+
code: string;
52+
clientId: string;
53+
code_verifier: string;
54+
}) {
55+
const url = new URL("/v2/oauth2/token", GLOBUS_AUTH_HOST);
56+
url.searchParams.set("grant_type", "authorization_code");
57+
url.searchParams.set("client_id", clientId);
58+
url.searchParams.set("redirect_uri", REDIRECT_URI);
59+
url.searchParams.set("code_verifier", code_verifier);
60+
url.searchParams.set("code", code);
61+
return url.toString();
62+
}
63+
64+
type GlobusLocalStorage = {
65+
authorizations?: {
66+
[resourceServer: string]: OAuth2Credentials;
67+
};
68+
/**
69+
* Globus Connect Server domain mappings.
70+
* Currently, there is no way to progrmatically determine the UUID of a GCS
71+
* endpoint from their domain name, so a user will need to provide a UUID
72+
* when attempting to access a file from a GCS endpoint.
73+
*/
74+
domainMappings?: {
75+
[domain: string]: string;
76+
};
77+
};
78+
79+
function getStorage() {
80+
return JSON.parse(
81+
localStorage.getItem("globus") || "{}",
82+
) as GlobusLocalStorage;
83+
}
84+
85+
async function waitForAuth(
86+
clientId: string,
87+
gcsHttpsHost: string,
88+
): Promise<OAuth2Credentials> {
89+
const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true);
90+
91+
const res: Promise<OAuth2Credentials> = new Promise((resolve) => {
92+
const frag = document.createDocumentFragment();
93+
94+
const title = document.createElement("h1");
95+
title.textContent = "Authenticate with Globus";
96+
title.style.fontSize = "1.5em";
97+
98+
frag.appendChild(title);
99+
100+
let identifier = getStorage().domainMappings?.[gcsHttpsHost];
101+
102+
const link = document.createElement("button");
103+
link.textContent = "Log in to Globus";
104+
link.disabled = true;
105+
106+
if (!identifier) {
107+
const label = document.createElement("label");
108+
label.textContent = "Globus Collection UUID";
109+
label.style.display = "block";
110+
label.style.margin = ".5em 0";
111+
frag.appendChild(label);
112+
const endpoint = document.createElement("input");
113+
endpoint.style.width = "100%";
114+
endpoint.style.margin = ".5em 0";
115+
endpoint.type = "text";
116+
endpoint.placeholder = "a17d7fac-ce06-4ede-8318-ad8dc98edd69";
117+
endpoint.addEventListener("input", async (e) => {
118+
identifier = (e.target as HTMLInputElement).value;
119+
link.disabled = !identifier;
120+
});
121+
frag.appendChild(endpoint);
122+
} else {
123+
link.disabled = false;
124+
}
125+
126+
link.addEventListener("click", async (event) => {
127+
event.preventDefault();
128+
if (!identifier) {
129+
status.setText("You must provide a Globus Collection UUID.");
130+
return;
131+
}
132+
const verifier = generateCodeVerifier();
133+
const state = getRandomHexString();
134+
const challenge = await generateCodeChallenge(verifier);
135+
const url = getGlobusAuthorizeURL({
136+
clientId,
137+
endpoint: identifier,
138+
code_challenge: challenge,
139+
state,
140+
});
141+
142+
const source = window.open(url, "_blank");
143+
if (!source) {
144+
status.setText("Failed to open login window.");
145+
return;
146+
}
147+
let rawToken:
148+
| {
149+
access_token: string;
150+
token_type: string;
151+
resource_server: string;
152+
}
153+
| undefined;
154+
const token = await waitForPKCEResponseMessage({
155+
source,
156+
state,
157+
cancellationToken: uncancelableToken,
158+
tokenExchangeCallback: async (code) => {
159+
const response = await fetch(
160+
getGlobusTokenURL({ clientId, code, code_verifier: verifier }),
161+
{
162+
method: "POST",
163+
headers: {
164+
"Content-Type": "application/x-www-form-urlencoded",
165+
},
166+
},
167+
);
168+
if (!response.ok) {
169+
throw new Error("Failed to exchange code for token");
170+
}
171+
rawToken = await response.json();
172+
if (!rawToken?.access_token || !rawToken?.token_type) {
173+
throw new Error("Invalid token response");
174+
}
175+
return {
176+
accessToken: rawToken.access_token,
177+
tokenType: rawToken.token_type,
178+
};
179+
},
180+
});
181+
182+
if (!rawToken) {
183+
status.setText("Failed to obtain token.");
184+
return;
185+
}
186+
187+
/**
188+
* We were able to obtain a token, store it in local storage along with
189+
* the domain mapping since we know it is correct.
190+
*/
191+
const storage = getStorage();
192+
storage.authorizations = {
193+
...storage.authorizations,
194+
[rawToken.resource_server]: token,
195+
};
196+
storage.domainMappings = {
197+
...storage.domainMappings,
198+
[gcsHttpsHost]: rawToken.resource_server,
199+
};
200+
201+
localStorage.setItem("globus", JSON.stringify(storage));
202+
resolve(token);
203+
});
204+
frag.appendChild(link);
205+
status.element.appendChild(frag);
206+
});
207+
208+
try {
209+
return await res;
210+
} finally {
211+
status.dispose();
212+
}
213+
}
214+
215+
export class GlobusCredentialsProvider extends CredentialsProvider<OAuth2Credentials> {
216+
constructor(
217+
public clientId: string,
218+
public gcsHttpsHost: string,
219+
) {
220+
super();
221+
}
222+
get = makeCredentialsGetter(async () => {
223+
const resourceServer = getStorage().domainMappings?.[this.gcsHttpsHost];
224+
const token = resourceServer
225+
? getStorage().authorizations?.[resourceServer]
226+
: undefined;
227+
if (!token) {
228+
return await waitForAuth(this.clientId, this.gcsHttpsHost);
229+
}
230+
const response = await fetch(`${this.gcsHttpsHost}`, {
231+
method: "HEAD",
232+
headers: {
233+
"X-Requested-With": "XMLHttpRequest",
234+
Authorization: `${token?.tokenType} ${token?.accessToken}`,
235+
},
236+
});
237+
switch (response.status) {
238+
case 200:
239+
return token;
240+
case 401:
241+
return await waitForAuth(this.clientId, this.gcsHttpsHost);
242+
default:
243+
throw HttpError.fromResponse(response);
244+
}
245+
});
246+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>Globus OAuth Redirect</title>
5+
<script>
6+
const data = Object.fromEntries(
7+
new URLSearchParams(location.search).entries(),
8+
);
9+
const target = window.opener || window.parent;
10+
if (target === window) {
11+
console.error("No opener/parent to receive successful oauth2 response");
12+
} else {
13+
target.postMessage(data, window.location.origin);
14+
}
15+
</script>
16+
</head>
17+
<body>
18+
<p>Globus authentication successful.</p>
19+
<p><button onclick="window.close()">Close</button></p>
20+
</body>
21+
</html>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defaultCredentialsManager } from "#src/credentials_provider/default_manager.js";
2+
import { GlobusCredentialsProvider } from "#src/datasource/globus/credentials_provider.js";
3+
4+
export declare const GLOBUS_CLIENT_ID: string | undefined;
5+
6+
export function isGlobusEnabled() {
7+
return typeof GLOBUS_CLIENT_ID !== "undefined";
8+
}
9+
10+
if (typeof GLOBUS_CLIENT_ID !== "undefined") {
11+
defaultCredentialsManager.register(
12+
"globus",
13+
(serverUrl) => new GlobusCredentialsProvider(GLOBUS_CLIENT_ID, serverUrl),
14+
);
15+
}

src/util/http_path_completion.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import type { CredentialsManager } from "#src/credentials_provider/index.js";
18+
import { isGlobusEnabled } from "#src/datasource/globus/register_credentials_provider.js";
1819
import type { CancellationToken } from "#src/util/cancellation.js";
1920
import type {
2021
BasicCompletionResult,
@@ -121,6 +122,13 @@ const specialProtocolEmptyCompletions: CompletionWithDescription[] = [
121122
{ value: "http://" },
122123
];
123124

125+
if (isGlobusEnabled()) {
126+
specialProtocolEmptyCompletions.push({
127+
value: "globus+https://",
128+
description: "Globus-sourced data authenticated via Globus Auth",
129+
});
130+
}
131+
124132
export async function completeHttpPath(
125133
credentialsManager: CredentialsManager,
126134
url: string,

0 commit comments

Comments
 (0)