c1b97adc3ea0faa1e9b685832150cfe59bf119b8
[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, createServices, setAuthorizationHeader } 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 } 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 invalidV2Token = "Must be a v2 token";
85
86 export const getSaltedToken = (clusterId: string, token: string) => {
87     const shaObj = new jsSHA("SHA-1", "TEXT");
88     const [ver, uuid, secret] = token.split("/");
89     if (ver !== "v2") {
90         throw new Error(invalidV2Token);
91     }
92     let salted = secret;
93     if (uuid.substr(0, 5) !== clusterId) {
94         shaObj.setHMACKey(secret, "TEXT");
95         shaObj.update(clusterId);
96         salted = shaObj.getHMAC("HEX");
97     }
98     return `v2/${uuid}/${salted}`;
99 };
100
101 export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active);
102
103 export const validateCluster = async (config: Config, useToken: string):
104     Promise<{ user: User; token: string }> => {
105
106     const saltedToken = getSaltedToken(config.uuidPrefix, useToken);
107
108     const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
109     setAuthorizationHeader(svc, saltedToken);
110
111     const user = await svc.authService.getUserDetails();
112     return {
113         user,
114         token: saltedToken,
115     };
116 };
117
118 export const validateSession = (session: Session, activeSession: Session) =>
119     async (dispatch: Dispatch): Promise<Session> => {
120         dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
121         session.loggedIn = false;
122
123         const setupSession = (baseUrl: string, user: User, token: string) => {
124             session.baseUrl = baseUrl;
125             session.token = token;
126             session.email = user.email;
127             session.uuid = user.uuid;
128             session.name = getUserFullname(user);
129             session.loggedIn = true;
130         };
131
132         let fail: Error | null = null;
133         const config = await getRemoteHostConfig(session.remoteHost);
134         if (config !== null) {
135             dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
136             try {
137                 const { user, token } = await validateCluster(config, session.token);
138                 setupSession(config.baseUrl, user, token);
139             } catch (e) {
140                 fail = new Error(`Getting current user for ${session.remoteHost}: ${e.message}`);
141                 try {
142                     const { user, token } = await validateCluster(config, activeSession.token);
143                     setupSession(config.baseUrl, user, token);
144                     fail = null;
145                 } catch (e2) {
146                     if (e.message === invalidV2Token) {
147                         fail = new Error(`Getting current user for ${session.remoteHost}: ${e2.message}`);
148                     }
149                 }
150             }
151         } else {
152             fail = new Error(`Could not get config for ${session.remoteHost}`);
153         }
154         session.status = SessionStatus.VALIDATED;
155         dispatch(authActions.UPDATE_SESSION(session));
156
157         if (fail) {
158             throw fail;
159         }
160
161         return session;
162     };
163
164 export const validateSessions = () =>
165     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
166         const sessions = getState().auth.sessions;
167         const activeSession = getActiveSession(sessions);
168         if (activeSession) {
169             dispatch(progressIndicatorActions.START_WORKING("sessionsValidation"));
170             for (const session of sessions) {
171                 if (session.status === SessionStatus.INVALIDATED) {
172                     try {
173                         /* Here we are dispatching a function, not an
174                            action.  This is legal (it calls the
175                            function with a 'Dispatch' object as the
176                            first parameter) but the typescript
177                            annotations don't understand this case, so
178                            we get an error from typescript unless
179                            override it using Dispatch<any>.  This
180                            pattern is used in a bunch of different
181                            places in Workbench2. */
182                         await dispatch(validateSession(session, activeSession));
183                     } catch (e) {
184                         dispatch(snackbarActions.OPEN_SNACKBAR({
185                             message: e.message,
186                             kind: SnackbarKind.ERROR
187                         }));
188                     }
189                 }
190             }
191             services.authService.saveSessions(getState().auth.sessions);
192             dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation"));
193         }
194     };
195
196 export const addSession = (remoteHost: string, token?: string, sendToLogin?: boolean) =>
197     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
198         const sessions = getState().auth.sessions;
199         const activeSession = getActiveSession(sessions);
200         let useToken: string | null = null;
201         if (token) {
202             useToken = token;
203         } else if (activeSession) {
204             useToken = activeSession.token;
205         }
206
207         if (useToken) {
208             const config = await getRemoteHostConfig(remoteHost);
209             if (!config) {
210                 dispatch(snackbarActions.OPEN_SNACKBAR({
211                     message: `Could not get config for ${remoteHost}`,
212                     kind: SnackbarKind.ERROR
213                 }));
214                 return;
215             }
216
217             try {
218                 dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config }));
219                 const { user, token } = await validateCluster(config, useToken);
220                 const session = {
221                     loggedIn: true,
222                     status: SessionStatus.VALIDATED,
223                     active: false,
224                     email: user.email,
225                     name: getUserFullname(user),
226                     uuid: user.uuid,
227                     baseUrl: config.baseUrl,
228                     clusterId: config.uuidPrefix,
229                     remoteHost,
230                     token
231                 };
232
233                 if (sessions.find(s => s.clusterId === config.uuidPrefix)) {
234                     await dispatch(authActions.UPDATE_SESSION(session));
235                 } else {
236                     await dispatch(authActions.ADD_SESSION(session));
237                 }
238                 services.authService.saveSessions(getState().auth.sessions);
239
240                 return session;
241             } catch {
242                 if (sendToLogin) {
243                     const rootUrl = new URL(config.baseUrl);
244                     rootUrl.pathname = "";
245                     window.location.href = `${rootUrl.toString()}/login?return_to=` + encodeURI(`${window.location.protocol}//${window.location.host}/add-session?baseURL=` + encodeURI(rootUrl.toString()));
246                     return;
247                 }
248             }
249         }
250         return Promise.reject(new Error("Could not validate cluster"));
251     };
252
253
254 export const removeSession = (clusterId: string) =>
255     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
256         await dispatch(authActions.REMOVE_SESSION(clusterId));
257         services.authService.saveSessions(getState().auth.sessions);
258     };
259
260 export const toggleSession = (session: Session) =>
261     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
262         const s: Session = { ...session };
263
264         if (session.loggedIn) {
265             s.loggedIn = false;
266             dispatch(authActions.UPDATE_SESSION(s));
267         } else {
268             const sessions = getState().auth.sessions;
269             const activeSession = getActiveSession(sessions);
270             if (activeSession) {
271                 try {
272                     await dispatch(validateSession(s, activeSession));
273                 } catch (e) {
274                     dispatch(snackbarActions.OPEN_SNACKBAR({
275                         message: e.message,
276                         kind: SnackbarKind.ERROR
277                     }));
278                     s.loggedIn = false;
279                     dispatch(authActions.UPDATE_SESSION(s));
280                 }
281             }
282         }
283
284         services.authService.saveSessions(getState().auth.sessions);
285     };
286
287 export const initSessions = (authService: AuthService, config: Config, user: User) =>
288     (dispatch: Dispatch<any>) => {
289         const sessions = authService.buildSessions(config, user);
290         dispatch(authActions.SET_SESSIONS(sessions));
291         dispatch(validateSessions());
292     };
293
294 export const loadSiteManagerPanel = () =>
295     async (dispatch: Dispatch<any>) => {
296         try {
297             dispatch(setBreadcrumbs([{ label: 'Site Manager' }]));
298             dispatch(validateSessions());
299         } catch (e) {
300             return;
301         }
302     };