11454: Remotes listed on remoteHosts show "enable/disable" action button
[arvados.git] / apps / workbench / app / assets / javascripts / models / session_db.js
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 window.SessionDB = function() {
6     var db = this
7     Object.assign(db, {
8         discoveryCache: {},
9         tokenUUIDCache: null,
10         loadFromLocalStorage: function() {
11             try {
12                 return JSON.parse(window.localStorage.getItem('sessions')) || {}
13             } catch(e) {}
14             return {}
15         },
16         loadAll: function() {
17             var all = db.loadFromLocalStorage()
18             if (window.defaultSession) {
19                 window.defaultSession.isFromRails = true
20                 all[window.defaultSession.user.uuid.slice(0, 5)] = window.defaultSession
21             }
22             return all
23         },
24         loadActive: function() {
25             var sessions = db.loadAll()
26             Object.keys(sessions).forEach(function(key) {
27                 if (!sessions[key].token || (sessions[key].user && !sessions[key].user.is_active))
28                     delete sessions[key]
29             })
30             return sessions
31         },
32         loadLocal: function() {
33             var sessions = db.loadActive()
34             var s = false
35             Object.values(sessions).forEach(function(session) {
36                 if (session.isFromRails) {
37                     s = session
38                     return
39                 }
40             })
41             return s
42         },
43         save: function(k, v) {
44             var sessions = db.loadAll()
45             sessions[k] = v
46             Object.keys(sessions).forEach(function(key) {
47                 if (sessions[key].isFromRails)
48                     delete sessions[key]
49             })
50             window.localStorage.setItem('sessions', JSON.stringify(sessions))
51         },
52         trash: function(k) {
53             var sessions = db.loadAll()
54             delete sessions[k]
55             window.localStorage.setItem('sessions', JSON.stringify(sessions))
56         },
57         findAPI: function(url) {
58             // Given a Workbench or API host or URL, return a promise
59             // for the corresponding API server's base URL.  Typical
60             // use:
61             // sessionDB.findAPI('https://workbench.example/foo').then(sessionDB.login)
62             if (url.length === 5 && url.indexOf('.') < 0)
63                 url += '.arvadosapi.com'
64             if (url.indexOf('://') < 0)
65                 url = 'https://' + url
66             url = new URL(url)
67             return m.request(url.origin + '/discovery/v1/apis/arvados/v1/rest').then(function() {
68                 return url.origin + '/'
69             }).catch(function(err) {
70                 // If url is a Workbench site (and isn't too old),
71                 // /status.json will tell us its API host.
72                 return m.request(url.origin + '/status.json').then(function(resp) {
73                     if (!resp.apiBaseURL)
74                         throw 'no apiBaseURL in status response'
75                     return resp.apiBaseURL
76                 })
77             })
78         },
79         login: function(baseURL, fallbackLogin = true) {
80             // Initiate login procedure with given API base URL (e.g.,
81             // "http://api.example/").
82             //
83             // Any page that has a button that invokes login() must
84             // also call checkForNewToken() on (at least) its first
85             // render. Otherwise, the login procedure can't be
86             // completed.
87             var session = db.loadLocal()
88             var uuidPrefix = session.user.owner_uuid.slice(0, 5)
89             var apiHostname = new URL(session.baseURL).hostname
90             m.request(session.baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(localDD) {
91                 m.request(baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(dd) {
92                     if (uuidPrefix in dd.remoteHosts ||
93                         (dd.remoteHostsViaDNS && apiHostname.indexOf('arvadosapi.com') >= 0)) {
94                         // Federated identity login via salted token
95                         db.saltedToken(dd.uuidPrefix).then(function(token) {
96                             m.request(baseURL+'arvados/v1/users/current', {
97                                 headers: {
98                                     authorization: 'Bearer '+token,
99                                 },
100                             }).then(function(user) {
101                                 // Federated login successful.
102                                 var remoteSession = {
103                                     user: user,
104                                     baseURL: baseURL,
105                                     token: token,
106                                     listedHost: (dd.uuidPrefix in localDD.remoteHosts),
107                                 }
108                                 db.save(dd.uuidPrefix, remoteSession)
109                             }).catch(function(e) {
110                                 if (dd.uuidPrefix in localDD.remoteHosts) {
111                                     // If the remote system is configured to allow federated
112                                     // logins from this cluster, but rejected the salted
113                                     // token, save as a logged out session anyways.
114                                     var remoteSession = {
115                                         baseURL: baseURL,
116                                         listedHost: true,
117                                     }
118                                     db.save(dd.uuidPrefix, remoteSession)
119                                 } else if (fallbackLogin) {
120                                     // Remote cluster not listed as a remote host and rejecting
121                                     // the salted token, try classic login.
122                                     db.loginClassic(baseURL)
123                                 }
124                             })
125                         })
126                     } else if (fallbackLogin) {
127                         // Classic login will be used when the remote system doesn't list this
128                         // cluster as part of the federation.
129                         db.loginClassic(baseURL)
130                     }
131                 })
132             })
133             return false
134         },
135         loginClassic: function(baseURL) {
136             document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
137         },
138         logout: function(k) {
139             // Forget the token, but leave the other info in the db so
140             // the user can log in again without providing the login
141             // host again.
142             var sessions = db.loadAll()
143             delete sessions[k].token
144             db.save(k, sessions[k])
145         },
146         saltedToken: function(uuid_prefix) {
147             // Takes a cluster UUID prefix and returns a salted token to allow
148             // log into said cluster using federated identity.
149             var session = db.loadLocal()
150             return db.tokenUUID().then(function(token_uuid){
151                 var shaObj = new jsSHA("SHA-1", "TEXT")
152                 shaObj.setHMACKey(session.token, "TEXT")
153                 shaObj.update(uuid_prefix)
154                 var hmac = shaObj.getHMAC("HEX")
155                 return 'v2/' + token_uuid + '/' + hmac
156             })
157         },
158         checkForNewToken: function() {
159             // If there's a token and baseURL in the location bar (i.e.,
160             // we just landed here after a successful login), save it and
161             // scrub the location bar.
162             if (document.location.search[0] != '?')
163                 return
164             var params = {}
165             document.location.search.slice(1).split('&').map(function(kv) {
166                 var e = kv.indexOf('=')
167                 if (e < 0)
168                     return
169                 params[decodeURIComponent(kv.slice(0, e))] = decodeURIComponent(kv.slice(e+1))
170             })
171             if (!params.baseURL || !params.api_token)
172                 // Have a query string, but it's not a login callback.
173                 return
174             params.token = params.api_token
175             delete params.api_token
176             db.save(params.baseURL, params)
177             history.replaceState({}, '', document.location.origin + document.location.pathname)
178         },
179         fillMissingUUIDs: function() {
180             var sessions = db.loadAll()
181             Object.keys(sessions).map(function(key) {
182                 if (key.indexOf('://') < 0)
183                     return
184                 // key is the baseURL placeholder. We need to get our user
185                 // record to find out the cluster's real uuid prefix.
186                 var session = sessions[key]
187                 m.request(session.baseURL+'arvados/v1/users/current', {
188                     headers: {
189                         authorization: 'OAuth2 '+session.token,
190                     },
191                 }).then(function(user) {
192                     session.user = user
193                     db.save(user.owner_uuid.slice(0, 5), session)
194                     db.trash(key)
195                 })
196             })
197         },
198         // Return the Workbench base URL advertised by the session's
199         // API server, or a reasonable guess, or (if neither strategy
200         // works out) null.
201         workbenchBaseURL: function(session) {
202             var dd = db.discoveryDoc(session)()
203             if (!dd)
204                 // Don't fall back to guessing until we receive the discovery doc
205                 return null
206             if (dd.workbenchUrl)
207                 return dd.workbenchUrl
208             // Guess workbench.{apihostport} is a Workbench... unless
209             // the host part of apihostport is an IPv4 or [IPv6]
210             // address.
211             if (!session.baseURL.match('://(\\[|\\d+\\.\\d+\\.\\d+\\.\\d+[:/])')) {
212                 var wbUrl = session.baseURL.replace('://', '://workbench.')
213                 // Remove the trailing slash, if it's there.
214                 return wbUrl.slice(-1) == '/' ? wbUrl.slice(0, -1) : wbUrl
215             }
216             return null
217         },
218         // Return a m.stream that will get fulfilled with the
219         // discovery doc from a session's API server.
220         discoveryDoc: function(session) {
221             var cache = db.discoveryCache[session.baseURL]
222             if (!cache) {
223                 db.discoveryCache[session.baseURL] = cache = m.stream()
224                 m.request(session.baseURL+'discovery/v1/apis/arvados/v1/rest').then(cache)
225             }
226             return cache
227         },
228         // Return a promise with the local session token's UUID from the API server.
229         tokenUUID: function() {
230             var cache = db.tokenUUIDCache
231             if (!cache) {
232                 var session = db.loadLocal()
233                 return db.request(session, '/arvados/v1/api_client_authorizations', {
234                     data: {
235                         filters: JSON.stringify([['api_token', '=', session.token]]),
236                     }
237                 }).then(function(resp) {
238                     var uuid = resp.items[0].uuid
239                     db.tokenUUIDCache = uuid
240                     return uuid
241                 })
242             } else {
243                 return new Promise(function(resolve, reject) {
244                     resolve(cache)
245                 })
246             }
247         },
248         request: function(session, path, opts) {
249             opts = opts || {}
250             opts.headers = opts.headers || {}
251             opts.headers.authorization = 'OAuth2 '+ session.token
252             return m.request(session.baseURL + path, opts)
253         },
254         // Check non-federated remote active sessions if they should be migrated to
255         // a salted token.
256         migrateNonFederatedSessions: function() {
257             var sessions = db.loadActive()
258             Object.keys(sessions).map(function(uuidPrefix) {
259                 session = sessions[uuidPrefix]
260                 if (!session.isFromRails && session.token && session.token.indexOf('v2/') < 0) {
261                     // Only try the federated login
262                     db.login(session.baseURL, false)
263                 }
264             })
265         },
266         // If remoteHosts is listed on the local API discovery doc, try to add any
267         // listed remote without an active session.
268         autoLoadRemoteHosts: function() {
269             var activeSessions = db.loadActive()
270             var doc = db.discoveryDoc(db.loadLocal())
271             doc.map(function(d) {
272                 Object.keys(d.remoteHosts).map(function(uuidPrefix) {
273                     if (!(uuidPrefix in Object.keys(activeSessions))) {
274                         db.findAPI(d.remoteHosts[uuidPrefix]).then(function(baseURL) {
275                             db.login(baseURL, false)
276                         })
277                     }
278                 })
279             })
280         },
281         // If the current logged in account is from a remote federated cluster,
282         // redirect the user to their home cluster's workbench.
283         // This is meant to avoid confusion when the user clicks through a search
284         // result on the home cluster's multi site search page, landing on the
285         // remote workbench and later trying to do another search by just clicking
286         // on the multi site search button instead of going back with the browser.
287         autoRedirectToHomeCluster: function(path = '/') {
288             var session = db.loadLocal()
289             var userUUIDPrefix = session.user.uuid.slice(0, 5)
290             // If the current user is local to the cluster, do nothing.
291             if (userUUIDPrefix == session.user.owner_uuid.slice(0, 5)) {
292                 return
293             }
294             var doc = db.discoveryDoc(session)
295             doc.map(function(d) {
296                 // Guess the remote host from the local discovery doc settings
297                 var rHost = null
298                 if (d.remoteHosts[userUUIDPrefix]) {
299                     rHost = d.remoteHosts[userUUIDPrefix]
300                 } else if (d.remoteHostsViaDNS) {
301                     rHost = userUUIDPrefix + '.arvadosapi.com'
302                 } else {
303                     // This should not happen: having remote user whose uuid prefix
304                     // isn't listed on remoteHosts and dns mechanism is deactivated
305                     return
306                 }
307                 // Get the remote cluster workbench url & redirect there.
308                 db.findAPI(rHost).then(function(apiUrl) {
309                     var doc = db.discoveryDoc({baseURL: apiUrl})
310                     doc.map(function(d) {
311                         document.location = d.workbenchUrl + path
312                     })
313                 })
314             })
315         },
316     })
317 }