-
Notifications
You must be signed in to change notification settings - Fork 313
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. |
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( | ||
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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
and granted access. Then the user gets directed to visit a Neuroglancer link that specifies a datasource of What will happen in that case? Will the user have to grant permission again or will it be assumed? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 As a way to validate the |
||
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); | ||
} | ||
}); | ||
} |
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> |
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}` |
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), | ||
); | ||
} |
There was a problem hiding this comment.
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