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