5b8acf9aeb061651b656590cd496c4a2d9e8bad8
[arvados-workbench2.git] / src / store / auth / auth-action-session.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { Dispatch } from "redux";
6 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
7 import { RootState } from "~/store/store";
8 import { ServiceRepository } from "~/services/services";
9 import Axios from "axios";
10 import { getUserFullname, User } from "~/models/user";
11 import { authActions } from "~/store/auth/auth-action";
12 import {
13     Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH,
14     buildConfig, mockClusterConfigJSON
15 } from "~/common/config";
16 import { normalizeURLPath } from "~/common/url";
17 import { Session, SessionStatus } from "~/models/session";
18 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
19 import { AuthService, UserDetailsResponse } from "~/services/auth-service/auth-service";
20 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
21 import * as jsSHA from "jssha";
22
23 const getClusterConfig = async (origin: string): Promise<Config | null> => {
24     // Try the new public config endpoint
25     try {
26         const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
27         return buildConfig(config);
28     } catch { }
29
30     // Fall back to discovery document
31     try {
32         const config = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
33         return {
34             baseUrl: normalizeURLPath(config.baseUrl),
35             keepWebServiceUrl: config.keepWebServiceUrl,
36             remoteHosts: config.remoteHosts,
37             rootUrl: config.rootUrl,
38             uuidPrefix: config.uuidPrefix,
39             websocketUrl: config.websocketUrl,
40             workbenchUrl: config.workbenchUrl,
41             workbench2Url: config.workbench2Url,
42             loginCluster: "",
43             vocabularyUrl: "",
44             fileViewersConfigUrl: "",
45             clusterConfig: mockClusterConfigJSON({})
46         };
47     } catch { }
48
49     return null;
50 };
51
52 const getRemoteHostConfig = async (remoteHost: string): Promise<Config | null> => {
53     let url = remoteHost;
54     if (url.indexOf('://') < 0) {
55         url = 'https://' + url;
56     }
57     const origin = new URL(url).origin;
58
59     // Maybe it is an API server URL, try fetching config and discovery doc
60     let r = await getClusterConfig(origin);
61     if (r !== null) {
62         return r;
63     }
64
65     // Maybe it is a Workbench2 URL, try getting config.json
66     try {
67         r = await getClusterConfig((await Axios.get<any>(`${origin}/config.json`)).data.API_HOST);
68         if (r !== null) {
69             return r;
70         }
71     } catch { }
72
73     // Maybe it is a Workbench1 URL, try getting status.json
74     try {
75         r = await getClusterConfig((await Axios.get<any>(`${origin}/status.json`)).data.apiBaseURL);
76         if (r !== null) {
77             return r;
78         }
79     } catch { }
80
81     return null;
82 };
83
84 const getUserDetails = async (baseUrl: string, token: string): Promise<UserDetailsResponse> => {
85     const resp = await Axios.get<UserDetailsResponse>(`${baseUrl}/users/current`, {
86         headers: {
87             Authorization: `OAuth2 ${token}`
88         }
89     });
90     return resp.data;
91 };
92
93 const invalidV2Token = "Must be a v2 token";
94
95 export const getSaltedToken = (clusterId: string, token: string) => {
96     const shaObj = new jsSHA("SHA-1", "TEXT");
97     const [ver, uuid, secret] = token.split("/");
98     if (ver !== "v2") {
99         throw new Error(invalidV2Token);
100     }
101     let salted = secret;
102     if (uuid.substr(0, 5) !== clusterId) {
103         shaObj.setHMACKey(secret, "TEXT");
104         shaObj.update(clusterId);
105         salted = shaObj.getHMAC("HEX");
106     }
107     return `v2/${uuid}/${salted}`;
108 };
109
110 export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active);
111
112 export const validateCluster = async (config: Config, useToken: string):
113     Promise<{ user: User; token: string }> => {
114
115     const saltedToken = getSaltedToken(config.uuidPrefix, useToken);
116     const user = await getUserDetails(config.baseUrl, saltedToken);
117     return {
118         user: {
119             firstName: user.first_name,
120             lastName: user.last_name,
121             uuid: user.uuid,
122             ownerUuid: user.owner_uuid,
123             email: user.email,
124             isAdmin: user.is_admin,
125             isActive: user.is_active,
126             username: user.username,
127             prefs: user.prefs
128         },
129         token: saltedToken,
130     };
131 };
132
133 export const validateSession = (session: Session, activeSession: Session) =>
134     async (dispatch: Dispatch): Promise<Session> => {
135         dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
136         session.loggedIn = false;
137
138         const setupSession = (baseUrl: string, user: User, token: string) => {
139             session.baseUrl = baseUrl;
140             session.token = token;
141             session.email = user.email;
142             session.uuid = user.uuid;
143             session.name = getUserFullname(user);
144             session.loggedIn = true;
145         };
146
147         let fail: Error | null = null;
148         const config = await getRemoteHostConfig(session.remoteHost);
149         if (config !== null) {
150             dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
151             try {
152                 const { user, token } = await validateCluster(config, session.token);
153                 setupSession(config.baseUrl, user, token);
154             } catch (e) {
155                 fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
156                 try {
157                     const { user, token } = await validateCluster(config, activeSession.token);
158                     setupSession(config.baseUrl, user, token);
159                     fail = null;
160                 } catch (e2) {
161                     if (e.message === invalidV2Token) {
162                         fail = new Error(`Getting current user for ${session.remoteHost}: ${e2.message}`);
163                     }
164                 }
165             }
166         } else {
167             fail = new Error(`Could not get config for ${session.remoteHost}`);
168         }
169         session.status = SessionStatus.VALIDATED;
170         dispatch(authActions.UPDATE_SESSION(session));
171
172         if (fail) {
173             throw fail;
174         }
175
176         return session;
177     };
178
179 export const validateSessions = () =>
180     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
181         const sessions = getState().auth.sessions;
182         const activeSession = getActiveSession(sessions);
183         if (activeSession) {
184             dispatch(progressIndicatorActions.START_WORKING("sessionsValidation"));
185             for (const session of sessions) {
186                 if (session.status === SessionStatus.INVALIDATED) {
187                     try {
188                         /* Here we are dispatching a function, not an
189                            action.  This is legal (it calls the
190                            function with a 'Dispatch' object as the
191                            first parameter) but the typescript
192                            annotations don't understand this case, so
193                            we get an error from typescript unless
194                            override it using Dispatch<any>.  This
195                            pattern is used in a bunch of different
196                            places in Workbench2. */
197                         await dispatch(validateSession(session, activeSession));
198                     } catch (e) {
199                         dispatch(snackbarActions.OPEN_SNACKBAR({
200                             message: e.message,
201                             kind: SnackbarKind.ERROR
202                         }));
203                     }
204                 }
205             }
206             services.authService.saveSessions(getState().auth.sessions);
207             dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation"));
208         }
209     };
210
211 export const addSession = (remoteHost: string, token?: string, sendToLogin?: boolean) =>
212     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
213         const sessions = getState().auth.sessions;
214         const activeSession = getActiveSession(sessions);
215         let useToken: string | null = null;
216         if (token) {
217             useToken = token;
218         } else if (activeSession) {
219             useToken = activeSession.token;
220         }
221
222         if (useToken) {
223             const config = await getRemoteHostConfig(remoteHost);
224             if (!config) {
225                 dispatch(snackbarActions.OPEN_SNACKBAR({
226                     message: `Could not get config for ${remoteHost}`,
227                     kind: SnackbarKind.ERROR
228                 }));
229                 return;
230             }
231
232             try {
233                 dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
234                 const { user, token } = await validateCluster(config, useToken);
235                 const session = {
236                     loggedIn: true,
237                     status: SessionStatus.VALIDATED,
238                     active: false,
239                     email: user.email,
240                     name: getUserFullname(user),
241                     uuid: user.uuid,
242                     baseUrl: config.baseUrl,
243                     clusterId: config.uuidPrefix,
244                     remoteHost,
245                     token
246                 };
247
248                 if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
249                     await dispatch(authActions.UPDATE_SESSION(session));
250                 } else {
251                     await dispatch(authActions.ADD_SESSION(session));
252                 }
253                 services.authService.saveSessions(getState().auth.sessions);
254
255                 return session;
256             } catch {
257                 if (sendToLogin) {
258                     const rootUrl = new URL(config.baseUrl);
259                     rootUrl.pathname = "";
260                     window.location.href = `${rootUrl.toString()}/login?return_to=` + encodeURI(`${window.location.protocol}//${window.location.host}/add-session?baseURL=` + encodeURI(rootUrl.toString()));
261                     return;
262                 }
263             }
264         }
265         return Promise.reject(new Error("Could not validate cluster"));
266     };
267
268
269 export const removeSession = (clusterId: string) =>
270     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
271         await dispatch(authActions.REMOVE_SESSION(clusterId));
272         services.authService.saveSessions(getState().auth.sessions);
273     };
274
275 export const toggleSession = (session: Session) =>
276     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
277         const s: Session = { ...session };
278
279         if (session.loggedIn) {
280             s.loggedIn = false;
281             dispatch(authActions.UPDATE_SESSION(s));
282         } else {
283             const sessions = getState().auth.sessions;
284             const activeSession = getActiveSession(sessions);
285             if (activeSession) {
286                 try {
287                     await dispatch(validateSession(s, activeSession));
288                 } catch (e) {
289                     dispatch(snackbarActions.OPEN_SNACKBAR({
290                         message: e.message,
291                         kind: SnackbarKind.ERROR
292                     }));
293                     s.loggedIn = false;
294                     dispatch(authActions.UPDATE_SESSION(s));
295                 }
296             }
297         }
298
299         services.authService.saveSessions(getState().auth.sessions);
300     };
301
302 export const initSessions = (authService: AuthService, config: Config, user: User) =>
303     (dispatch: Dispatch<any>) => {
304         const sessions = authService.buildSessions(config, user);
305         dispatch(authActions.SET_SESSIONS(sessions));
306         dispatch(validateSessions());
307     };
308
309 export const loadSiteManagerPanel = () =>
310     async (dispatch: Dispatch<any>) => {
311         try {
312             dispatch(setBreadcrumbs([{ label: 'Site Manager' }]));
313             dispatch(validateSessions());
314         } catch (e) {
315             return;
316         }
317     };