11454: Do federated logins by requesting the current user object
[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(rhosts) {
6     var db = this
7     Object.assign(db, {
8         remoteHosts: rhosts || [],
9         discoveryCache: {},
10         tokenUUIDCache: null,
11         loadFromLocalStorage: function() {
12             try {
13                 return JSON.parse(window.localStorage.getItem('sessions')) || {}
14             } catch(e) {}
15             return {}
16         },
17         loadAll: function() {
18             var all = db.loadFromLocalStorage()
19             if (window.defaultSession) {
20                 window.defaultSession.isFromRails = true
21                 all[window.defaultSession.user.uuid.slice(0, 5)] = window.defaultSession
22             }
23             return all
24         },
25         loadActive: function() {
26             var sessions = db.loadAll()
27             Object.keys(sessions).forEach(function(key) {
28                 if (!sessions[key].token || !sessions[key].user.is_active)
29                     delete sessions[key]
30             })
31             return sessions
32         },
33         loadLocal: function() {
34             var sessions = db.loadActive()
35             var s = false
36             Object.values(sessions).forEach(function(session) {
37                 if (session.isFromRails) {
38                     s = session
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             window.localStorage.setItem('sessions', JSON.stringify(sessions))
52         },
53         trash: function(k) {
54             var sessions = db.loadAll()
55             delete sessions[k]
56             window.localStorage.setItem('sessions', JSON.stringify(sessions))
57         },
58         findAPI: function(url) {
59             // Given a Workbench or API host or URL, return a promise
60             // for the corresponding API server's base URL.  Typical
61             // use:
62             // sessionDB.findAPI('https://workbench.example/foo').then(sessionDB.login)
63             if (url.indexOf('://') < 0)
64                 url = 'https://' + url
65             url = new URL(url)
66             return m.request(url.origin + '/discovery/v1/apis/arvados/v1/rest').then(function() {
67                 return url.origin + '/'
68             }).catch(function(err) {
69                 // If url is a Workbench site (and isn't too old),
70                 // /status.json will tell us its API host.
71                 return m.request(url.origin + '/status.json').then(function(resp) {
72                     if (!resp.apiBaseURL)
73                         throw 'no apiBaseURL in status response'
74                     return resp.apiBaseURL
75                 })
76             })
77         },
78         login: function(baseURL) {
79             // Initiate login procedure with given API base URL (e.g.,
80             // "http://api.example/").
81             //
82             // Any page that has a button that invokes login() must
83             // also call checkForNewToken() on (at least) its first
84             // render. Otherwise, the login procedure can't be
85             // completed.
86             var session = db.loadLocal()
87             var uuidPrefix = session.user.owner_uuid.slice(0, 5)
88             var apiHostname = new URL(session.baseURL).hostname
89             m.request(baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(dd) {
90                 if (uuidPrefix in dd.remoteHosts ||
91                     (dd.remoteHostsViaDNS && apiHostname.indexOf('arvadosapi.com') >= 0)) {
92                     // Federated identity login via salted token
93                     db.saltedToken(dd.uuidPrefix).then(function(token) {
94                         m.request(baseURL+'arvados/v1/users/current', {
95                             headers: {
96                                 authorization: 'Bearer '+token,
97                             },
98                         }).then(function(user) {
99                             var remoteSession = {
100                                 user: user,
101                                 baseURL: baseURL,
102                                 token: token
103                             }
104                             db.save(user.owner_uuid.slice(0, 5), remoteSession)
105                         })
106                     })
107                 } else {
108                     // Classic login
109                     document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
110                 }
111             })
112             return false
113         },
114         logout: function(k) {
115             // Forget the token, but leave the other info in the db so
116             // the user can log in again without providing the login
117             // host again.
118             var sessions = db.loadAll()
119             delete sessions[k].token
120             db.save(k, sessions[k])
121         },
122         saltedToken: function(uuid_prefix) {
123             // Takes a cluster UUID prefix and returns a salted token to allow
124             // log into said cluster using federated identity.
125             var session = db.loadLocal()
126             return db.tokenUUID().then(function(token_uuid){
127                 var shaObj = new jsSHA("SHA-1", "TEXT")
128                 shaObj.setHMACKey(session.token, "TEXT")
129                 shaObj.update(uuid_prefix)
130                 var hmac = shaObj.getHMAC("HEX")
131                 return 'v2/' + token_uuid + '/' + hmac
132             })
133         },
134         checkForNewToken: function() {
135             // If there's a token and baseURL in the location bar (i.e.,
136             // we just landed here after a successful login), save it and
137             // scrub the location bar.
138             if (document.location.search[0] != '?')
139                 return
140             var params = {}
141             document.location.search.slice(1).split('&').map(function(kv) {
142                 var e = kv.indexOf('=')
143                 if (e < 0)
144                     return
145                 params[decodeURIComponent(kv.slice(0, e))] = decodeURIComponent(kv.slice(e+1))
146             })
147             if (!params.baseURL || !params.api_token)
148                 // Have a query string, but it's not a login callback.
149                 return
150             params.token = params.api_token
151             delete params.api_token
152             db.save(params.baseURL, params)
153             history.replaceState({}, '', document.location.origin + document.location.pathname)
154         },
155         fillMissingUUIDs: function() {
156             var sessions = db.loadAll()
157             Object.keys(sessions).map(function(key) {
158                 if (key.indexOf('://') < 0)
159                     return
160                 // key is the baseURL placeholder. We need to get our user
161                 // record to find out the cluster's real uuid prefix.
162                 var session = sessions[key]
163                 m.request(session.baseURL+'arvados/v1/users/current', {
164                     headers: {
165                         authorization: 'OAuth2 '+session.token,
166                     },
167                 }).then(function(user) {
168                     session.user = user
169                     db.save(user.owner_uuid.slice(0, 5), session)
170                     db.trash(key)
171                 })
172             })
173         },
174         // Return the Workbench base URL advertised by the session's
175         // API server, or a reasonable guess, or (if neither strategy
176         // works out) null.
177         workbenchBaseURL: function(session) {
178             var dd = db.discoveryDoc(session)()
179             if (!dd)
180                 // Don't fall back to guessing until we receive the discovery doc
181                 return null
182             if (dd.workbenchUrl)
183                 return dd.workbenchUrl
184             // Guess workbench.{apihostport} is a Workbench... unless
185             // the host part of apihostport is an IPv4 or [IPv6]
186             // address.
187             if (!session.baseURL.match('://(\\[|\\d+\\.\\d+\\.\\d+\\.\\d+[:/])')) {
188                 var wbUrl = session.baseURL.replace('://', '://workbench.')
189                 // Remove the trailing slash, if it's there.
190                 return wbUrl.slice(-1) == '/' ? wbUrl.slice(0, -1) : wbUrl
191             }
192             return null
193         },
194         // Return a m.stream that will get fulfilled with the
195         // discovery doc from a session's API server.
196         discoveryDoc: function(session) {
197             var cache = db.discoveryCache[session.baseURL]
198             if (!cache) {
199                 db.discoveryCache[session.baseURL] = cache = m.stream()
200                 m.request(session.baseURL+'discovery/v1/apis/arvados/v1/rest').then(cache)
201             }
202             return cache
203         },
204         // Return a promise with the local session token's UUID from the API server.
205         tokenUUID: function() {
206             var cache = db.tokenUUIDCache
207             if (!cache) {
208                 var session = db.loadLocal()
209                 return db.request(session, '/arvados/v1/api_client_authorizations', {
210                     data: {
211                         filters: JSON.stringify([['api_token', '=', session.token]]),
212                     }
213                 }).then(function(resp) {
214                     var uuid = resp.items[0].uuid
215                     db.tokenUUIDCache = uuid
216                     return uuid
217                 })
218             } else {
219                 return new Promise(function(resolve, reject) {
220                     resolve(cache)
221                 })
222             }
223         },
224         request: function(session, path, opts) {
225             opts = opts || {}
226             opts.headers = opts.headers || {}
227             opts.headers.authorization = 'OAuth2 '+ session.token
228             return m.request(session.baseURL + path, opts)
229         },
230     })
231 }