11454: Conditional login to remote API servers.
[arvados.git] / apps / workbench / app / assets / javascripts / models / session_db.js
index b64481eee061c0a004e3f55229e969224ec613a1..79e98ca37e84a0b577e5c342e6a2e804cdf355eb 100644 (file)
@@ -2,10 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-window.models = window.models || {}
-window.models.SessionDB = function() {
+window.SessionDB = function(rhosts) {
     var db = this
     Object.assign(db, {
+        remoteHosts: rhosts || [],
+        discoveryCache: {},
         loadFromLocalStorage: function() {
             try {
                 return JSON.parse(window.localStorage.getItem('sessions')) || {}
@@ -28,6 +29,17 @@ window.models.SessionDB = function() {
             })
             return sessions
         },
+        loadLocal: function() {
+            var sessions = db.loadActive()
+            var s = false
+            Object.values(sessions).forEach(function(session) {
+                if (session.isFromRails) {
+                    s = session
+                    return
+                }
+            })
+            return s
+        },
         save: function(k, v) {
             var sessions = db.loadAll()
             sessions[k] = v
@@ -42,20 +54,49 @@ window.models.SessionDB = function() {
             delete sessions[k]
             window.localStorage.setItem('sessions', JSON.stringify(sessions))
         },
-        login: function(host) {
-            // Initiate login procedure with given API host (which can
-            // optionally include scheme://).
+        findAPI: function(url) {
+            // Given a Workbench or API host or URL, return a promise
+            // for the corresponding API server's base URL.  Typical
+            // use:
+            // sessionDB.findAPI('https://workbench.example/foo').then(sessionDB.login)
+            if (url.indexOf('://') < 0)
+                url = 'https://' + url
+            url = new URL(url)
+            return m.request(url.origin + '/discovery/v1/apis/arvados/v1/rest').then(function() {
+                return url.origin + '/'
+            }).catch(function(err) {
+                // If url is a Workbench site (and isn't too old),
+                // /status.json will tell us its API host.
+                return m.request(url.origin + '/status.json').then(function(resp) {
+                    if (!resp.apiBaseURL)
+                        throw 'no apiBaseURL in status response'
+                    return resp.apiBaseURL
+                })
+            })
+        },
+        login: function(baseURL) {
+            // Initiate login procedure with given API base URL (e.g.,
+            // "http://api.example/").
             //
             // Any page that has a button that invokes login() must
             // also call checkForNewToken() on (at least) its first
             // render. Otherwise, the login procedure can't be
             // completed.
-            var baseURL = host
-            if (baseURL.indexOf('://') < 0)
-                baseURL = 'https://' + baseURL
-            if (!baseURL.endsWith('/'))
-                baseURL = baseURL + '/'
-            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
+            var session = db.loadLocal()
+            var uuidPrefix = session.user.owner_uuid.slice(0, 5)
+            var apiHostname = new URL(session.baseURL).hostname
+            m.request(baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(dd) {
+                if (uuidPrefix in dd.remoteHosts ||
+                    (dd.remoteHostsViaDNS && apiHostname.indexOf('arvadosapi.com') >= 0)) {
+                    // Federated identity login via salted token
+                    db.saltedToken(dd.uuidPrefix).then(function(token) {
+                        document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL)) + '&api_token='+encodeURIComponent(token)
+                    })
+                } else {
+                    // Classic login
+                    document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
+                }
+            })
             return false
         },
         logout: function(k) {
@@ -66,11 +107,32 @@ window.models.SessionDB = function() {
             delete sessions[k].token
             db.save(k, sessions[k])
         },
+        saltedToken: function(uuid_prefix) {
+            // Takes a cluster UUID prefix and returns a salted token to allow
+            // log into said cluster using federated identity.
+            var session = db.loadLocal()
+            return db.request(session, '/arvados/v1/api_client_authorizations', {
+                data: {
+                    filters: JSON.stringify([['api_token', '=', session.token]]),
+                }
+            }).then(function(resp) {
+                if (resp.items.length == 1) {
+                    var token_uuid = resp.items[0].uuid
+                    if (token_uuid.length !== '') {
+                        var shaObj = new jsSHA("SHA-1", "TEXT")
+                        shaObj.setHMACKey(session.token, "TEXT")
+                        shaObj.update(uuid_prefix)
+                        var hmac = shaObj.getHMAC("HEX")
+                        return 'v2/' + token_uuid + '/' + hmac
+                    } else { return null }
+                }
+            }).catch(function(err) { return null })
+        },
         checkForNewToken: function() {
             // If there's a token and baseURL in the location bar (i.e.,
             // we just landed here after a successful login), save it and
             // scrub the location bar.
-            if (!document.location.search.startsWith('?'))
+            if (document.location.search[0] != '?')
                 return
             var params = {}
             document.location.search.slice(1).split('&').map(function(kv) {
@@ -101,11 +163,40 @@ window.models.SessionDB = function() {
                     },
                 }).then(function(user) {
                     session.user = user
-                    db.save(user.uuid.slice(0, 5), session)
+                    db.save(user.owner_uuid.slice(0, 5), session)
                     db.trash(key)
                 })
             })
-            // m.request(session.baseURL + 'discovery/v1/apis/arvados/v1/rest').then(function(dd) {})
+        },
+        // Return the Workbench base URL advertised by the session's
+        // API server, or a reasonable guess, or (if neither strategy
+        // works out) null.
+        workbenchBaseURL: function(session) {
+            var dd = db.discoveryDoc(session)()
+            if (!dd)
+                // Don't fall back to guessing until we receive the discovery doc
+                return null
+            if (dd.workbenchUrl)
+                return dd.workbenchUrl
+            // Guess workbench.{apihostport} is a Workbench... unless
+            // the host part of apihostport is an IPv4 or [IPv6]
+            // address.
+            if (!session.baseURL.match('://(\\[|\\d+\\.\\d+\\.\\d+\\.\\d+[:/])')) {
+                var wbUrl = session.baseURL.replace('://', '://workbench.')
+                // Remove the trailing slash, if it's there.
+                return wbUrl.slice(-1) == '/' ? wbUrl.slice(0, -1) : wbUrl
+            }
+            return null
+        },
+        // Return a m.stream that will get fulfilled with the
+        // discovery doc from a session's API server.
+        discoveryDoc: function(session) {
+            var cache = db.discoveryCache[session.baseURL]
+            if (!cache) {
+                db.discoveryCache[session.baseURL] = cache = m.stream()
+                m.request(session.baseURL+'discovery/v1/apis/arvados/v1/rest').then(cache)
+            }
+            return cache
         },
         request: function(session, path, opts) {
             opts = opts || {}