Repository: ORNL/DataFed (language: python)

Issue:
[Core, Database, Globus] Create consent URI for accessing mapped collections
# Description
The goal of this ticket is to support #1116 by providing a URI for users to follow.

# Context:
With DataFed, a user needs consent to transfer a file to a mapped collection. This requires sending a request to the [Globus Auth API](https://docs.globus.org/api/auth/reference/) with appropriate Scopes (transfer:all) as a request signed with the client credentials. The request should also point to the client's specified callback URI, where an authorization code will be returned for further processing by the application to exchange for a token.

![Image](https://github.com/user-attachments/assets/a91fe43e-4db9-4e56-afb8-99a1943ec178)


# Useful links:
[Globus Auth Client `oauth2_get_authorize_url`](https://globus-sdk-python.readthedocs.io/en/stable/services/auth.html#globus_sdk.AuthLoginClient.oauth2_get_authorize_url)

# Acceptance
The user consent URI is provided to the end user to follow.


Discussion / hints:
I'm marking the "Estimate" field as hours I believe this may take to finish. I am heavily padding these as I get started with DataFed

Fix patch:
diff --git a/web/static/util.js b/web/static/util.js
index 847620962..20257aeaa 100644
--- a/web/static/util.js
+++ b/web/static/util.js
@@ -714,3 +714,56 @@ function _schemaResolveRefs( a_props, a_refs ){
     }
 }
 */
+
+/**
+ * Basic implementation of get_authorize_url from Globus SDK
+ *  @param {UUID} client_id The UUID of the Globus authentication client
+ *  @param {string} redirect_uri The URI safe application-wide Globus Auth redirect URI.
+ *  @param {Array<string>}requested_scopes The scopes on the token(s) being requested
+ *      In the case of accessing a mapped collection, this should include the mapped collection's UUID
+ *      like so: https://auth.globus.org/scopes/YOUR-UUID-HERE/data_access
+ *  @param {string} state Allows the application to pass information back to itself
+ *  @param {boolean} refresh_tokens Request refresh tokens in addition to access tokens
+ *  @param {object} query_params Additional params
+ *  @returns {string} The URL a user can follow to provide authorization and consent via Globus
+ */
+export function globusGetAuthorizeURL(client_id, redirect_uri, requested_scopes=[], state="_default", refresh_tokens=false, query_params={}) {
+    if ( !client_id || !redirect_uri ) {
+        throw new Error("Missing required parameters, please provide client_id and redirect_uri");
+    }
+    if (!requested_scopes?.length) {
+        requested_scopes = [
+            "openid", "profile", "email",
+            "urn:globus:auth:scope:transfer.api.globus.org:all"
+        ];
+    }
+    // TODO: stubbed client object to provide base url, we should extract if possible
+    const auth_client = {
+        base_url: "https://auth.globus.org",
+    };
+    const authorize_base_url = auth_client.base_url + "/v2/oauth2/authorize";
+    let required_scopes = [...requested_scopes];
+    if (refresh_tokens) {
+        required_scopes = [...requested_scopes, "offline_access"];
+    }
+    /*
+        NOTE: using URLSearchParams changes encoding of  " " to "+" which Globus accepts, despite saying otherwise
+        https://docs.globus.org/api/auth/developer-guide/#obtaining-authorization
+    */
+    // TODO: consider moving back to custom encoding in anticipation that Globus will no longer accept a different encoding scheme
+    let params = new URLSearchParams({
+        "client_id": client_id,
+        "redirect_uri": redirect_uri,
+        "scope": required_scopes.join(" "),    // Scopes need to be separated by a space
+        "state": state,
+        "response_type": "code",
+        "access_type": refresh_tokens ? "offline" : "online",
+        "prompt": "login",
+    });
+    Object.entries(query_params).forEach(([key, value]) => {
+       if (!!value && !params.has(key)) { // short-circuit on empty param values or if param already defined, TODO: are there cases where we may want empty params?
+           params.set(key, value);
+       }
+    });
+    return authorize_base_url + "?" + params.toString();
+}


Test patch:
diff --git a/web/test/util.test.js b/web/test/util.test.js
index 6c5d58060..800feda07 100644
--- a/web/test/util.test.js
+++ b/web/test/util.test.js
@@ -1,5 +1,5 @@
 import { expect } from 'chai';
-import { isObjEmpty } from '../static/util.js';
+import { isObjEmpty, globusGetAuthorizeURL } from '../static/util.js';
 
 describe('isObjEmpty', function () {
   it('should return true for an empty object', function () {
@@ -21,3 +21,103 @@ describe('isObjEmpty', function () {
   });
 });
 
+describe('globusGetAuthorizeURL', () => {
+  const client_id = "39c655a9-a428-46fa-94bb-078feab6acb1";
+  const redirect_uri = "https://fake.website.datafed/ui/authn";
+  const requested_scopes = [
+    "urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/e8b9afc1-dabf-45e9-9743-d5eda7c914c9/data_access]",
+    "urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/5066556a-bcd6-4e00-8e3f-b45e0ec88b1a/data_access]"
+  ];
+  const state = "none";
+  const refresh_tokens = true;
+  const query_params = {};
+  it('should return a valid URL', () => {
+    expect(
+        Boolean(new URL(
+            globusGetAuthorizeURL(
+                client_id,
+                redirect_uri,
+                requested_scopes,
+                state,
+                refresh_tokens,
+                query_params
+            )
+            )
+        )
+    ).to.be.true;
+  });
+
+  it('should contain requested information', () => {
+    const auth_url = globusGetAuthorizeURL(client_id, redirect_uri, requested_scopes, state, refresh_tokens, query_params);
+    expect(auth_url).to.have.string(client_id);
+    expect(auth_url).to.have.string(encodeURIComponent(redirect_uri));
+    requested_scopes.forEach((scope_str) => {
+      expect(auth_url).to.have.string(encodeURIComponent(scope_str));
+    });
+    expect(auth_url).to.have.string(state);
+    expect(auth_url).to.have.string(refresh_tokens ? "offline" : "online");
+    expect(auth_url).to.have.string(refresh_tokens ? "offline_access" : "");
+  });
+
+  it('should contain additional specified query parameters', ()=> {
+    const clean_param = {"other": "params"};
+    const unsafe_value_param = {"that": "we/"};
+    const unsafe_key_param = {"may || not": "see"};
+    const no_value_param = {"empty": ""}
+    const specified_params = {...clean_param, ...unsafe_value_param, ...unsafe_key_param, ...no_value_param};
+    const auth_url = globusGetAuthorizeURL(client_id, redirect_uri, requested_scopes, state, refresh_tokens, specified_params);
+
+    // clean params
+    const clean_key = Object.keys(clean_param)[0];
+    const clean_value = Object.values(clean_param)[0];
+    expect(auth_url).to.have.string(clean_key + "=" + clean_value);
+
+    // unsafe values
+    const unsafe_value_key = Object.keys(unsafe_value_param)[0];
+    const unsafe_value_value = Object.values(unsafe_value_param)[0];
+    expect(auth_url).to.have.string(unsafe_value_key + "=");
+    expect(auth_url).to.not.have.string(unsafe_value_value);
+
+    //unsafe keys
+    const unsafe_key_key = Object.keys(unsafe_key_param)[0];
+    const unsafe_key_value = Object.values(unsafe_key_param)[0];
+    expect(auth_url).to.not.have.string(unsafe_key_key);
+    expect(auth_url).to.have.string(unsafe_key_value);
+
+    //no values
+    const no_value_key = Object.keys(no_value_param)[0];
+    expect(auth_url).to.not.have.string(no_value_key);
+  });
+
+  it('should create a valid URL when given minimal parameters', () => {
+    const auth_url = globusGetAuthorizeURL(client_id, redirect_uri);
+    expect(Boolean(new URL(auth_url))).to.be.true;
+  });
+
+  it('should throw an error when client_id is not provided', () => {
+    expect(() => globusGetAuthorizeURL(null, redirect_uri)).to.throw(Error, "Missing required parameters, please provide client_id and redirect_uri");
+  });
+  it('should throw an error when redirect_uri is not provided', () => {
+    expect(() => globusGetAuthorizeURL(client_id, null)).to.throw(Error, "Missing required parameters, please provide client_id and redirect_uri");
+  });
+
+  it('should provide default scopes when no scopes are provided', () => {
+    const auth_url = globusGetAuthorizeURL(client_id, redirect_uri);
+    const default_scopes = [
+      "openid", "profile", "email",
+      "urn:globus:auth:scope:transfer.api.globus.org:all"
+    ];
+    default_scopes.forEach((default_scope) => {
+      expect(auth_url).to.have.string(encodeURIComponent(default_scope));
+    })
+  });
+
+  it('should have encoded url params', () => {
+    const auth_url_string = globusGetAuthorizeURL(client_id, redirect_uri, requested_scopes, state, refresh_tokens, query_params);
+    const decoded_url = decodeURIComponent(auth_url_string);
+
+    // NOTE: encoding changed during URLSearchParams.toString from " " to "+"
+    expect(decoded_url).to.have.string("scope=" + requested_scopes.join("+"));
+  });
+});
+