15736: "add-session" route, support tokens received from other clusters
[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 { Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH, ARVADOS_API_PATH } from "~/common/config";
13 import { normalizeURLPath } from "~/common/url";
14 import { Session, SessionStatus } from "~/models/session";
15 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
16 import { AuthService, UserDetailsResponse } from "~/services/auth-service/auth-service";
17 import * as jsSHA from "jssha";
18
19 const getClusterInfo = async (origin: string): Promise<{ clusterId: string, baseURL: string } | null> => {
20     // Try the new public config endpoint
21     try {
22         const config = (await Axios.get<ClusterConfigJSON>(`${origin}/${CLUSTER_CONFIG_PATH}`)).data;
23         return {
24             clusterId: config.ClusterID,
25             baseURL: normalizeURLPath(`${config.Services.Controller.ExternalURL}/${ARVADOS_API_PATH}`)
26         };
27     } catch { }
28
29     // Fall back to discovery document
30     try {
31         const config = (await Axios.get<any>(`${origin}/${DISCOVERY_DOC_PATH}`)).data;
32         return {
33             clusterId: config.uuidPrefix,
34             baseURL: normalizeURLPath(config.baseUrl)
35         };
36     } catch { }
37
38     return null;
39 };
40
41 const getRemoteHostInfo = async (remoteHost: string): Promise<{ clusterId: string, baseURL: string } | null> => {
42     let url = remoteHost;
43     if (url.indexOf('://') < 0) {
44         url = 'https://' + url;
45     }
46     const origin = new URL(url).origin;
47
48     // Maybe it is an API server URL, try fetching config and discovery doc
49     let r = getClusterInfo(origin);
50     if (r !== null) {
51         return r;
52     }
53
54     // Maybe it is a Workbench2 URL, try getting config.json
55     try {
56         r = getClusterInfo((await Axios.get<any>(`${origin}/config.json`)).data.API_HOST);
57         if (r !== null) {
58             return r;
59         }
60     } catch { }
61
62     // Maybe it is a Workbench1 URL, try getting status.json
63     try {
64         r = getClusterInfo((await Axios.get<any>(`${origin}/status.json`)).data.apiBaseURL);
65         if (r !== null) {
66             return r;
67         }
68     } catch { }
69
70     return null;
71 };
72
73 const getUserDetails = async (baseUrl: string, token: string): Promise<UserDetailsResponse> => {
74     const resp = await Axios.get<UserDetailsResponse>(`${baseUrl}/users/current`, {
75         headers: {
76             Authorization: `OAuth2 ${token}`
77         }
78     });
79     return resp.data;
80 };
81
82 export const getSaltedToken = (clusterId: string, token: string) => {
83     const shaObj = new jsSHA("SHA-1", "TEXT");
84     const [ver, uuid, secret] = token.split("/");
85     if (ver !== "v2") {
86         throw new Error("Must be a v2 token");
87     }
88     let salted = secret;
89     if (uuid.substr(0, 5) !== clusterId) {
90         shaObj.setHMACKey(secret, "TEXT");
91         shaObj.update(clusterId);
92         salted = shaObj.getHMAC("HEX");
93     }
94     return `v2/${uuid}/${salted}`;
95 };
96
97 export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active);
98
99 export const validateCluster = async (remoteHost: string, useToken: string):
100     Promise<{ user: User; token: string, baseUrl: string, clusterId: string }> => {
101
102     const info = await getRemoteHostInfo(remoteHost);
103     if (!info) {
104         return Promise.reject(`Could not get config for ${remoteHost}`);
105     }
106     const saltedToken = getSaltedToken(info.clusterId, useToken);
107     const user = await getUserDetails(info.baseURL, saltedToken);
108     return {
109         baseUrl: info.baseURL,
110         user: {
111             firstName: user.first_name,
112             lastName: user.last_name,
113             uuid: user.uuid,
114             ownerUuid: user.owner_uuid,
115             email: user.email,
116             isAdmin: user.is_admin,
117             isActive: user.is_active,
118             username: user.username,
119             prefs: user.prefs
120         },
121         token: saltedToken,
122         clusterId: info.clusterId
123     };
124 };
125
126 export const validateSession = (session: Session, activeSession: Session) =>
127     async (dispatch: Dispatch): Promise<Session> => {
128         dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED }));
129         session.loggedIn = false;
130
131         const setupSession = (baseUrl: string, user: User, token: string) => {
132             session.baseUrl = baseUrl;
133             session.token = token;
134             session.email = user.email;
135             session.uuid = user.uuid;
136             session.name = getUserFullname(user);
137             session.loggedIn = true;
138         };
139
140         try {
141             const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.token);
142             setupSession(baseUrl, user, token);
143         } catch {
144             try {
145                 const { baseUrl, user, token } = await validateCluster(session.remoteHost, activeSession.token);
146                 setupSession(baseUrl, user, token);
147             } catch { }
148         }
149
150         session.status = SessionStatus.VALIDATED;
151         dispatch(authActions.UPDATE_SESSION(session));
152
153         return session;
154     };
155
156 export const validateSessions = () =>
157     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
158         const sessions = getState().auth.sessions;
159         const activeSession = getActiveSession(sessions);
160         if (activeSession) {
161             dispatch(progressIndicatorActions.START_WORKING("sessionsValidation"));
162             for (const session of sessions) {
163                 if (session.status === SessionStatus.INVALIDATED) {
164                     await dispatch(validateSession(session, activeSession));
165                 }
166             }
167             services.authService.saveSessions(sessions);
168             dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation"));
169         }
170     };
171
172 export const addSession = (remoteHost: string, token?: string) =>
173     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
174         const sessions = getState().auth.sessions;
175         const activeSession = getActiveSession(sessions);
176         let useToken: string | null = null;
177         if (token) {
178             useToken = token;
179         } else if (activeSession) {
180             useToken = activeSession.token;
181         }
182
183         if (useToken) {
184             try {
185                 const { baseUrl, user, token, clusterId } = await validateCluster(remoteHost, useToken);
186                 const session = {
187                     loggedIn: true,
188                     status: SessionStatus.VALIDATED,
189                     active: false,
190                     email: user.email,
191                     name: getUserFullname(user),
192                     uuid: user.uuid,
193                     remoteHost,
194                     baseUrl,
195                     clusterId,
196                     token
197                 };
198
199                 if (sessions.find(s => s.clusterId === clusterId)) {
200                     dispatch(authActions.UPDATE_SESSION(session));
201                 } else {
202                     dispatch(authActions.ADD_SESSION(session));
203                 }
204                 services.authService.saveSessions(getState().auth.sessions);
205
206                 return session;
207             } catch (e) {
208             }
209         }
210         return Promise.reject("Could not validate cluster");
211     };
212
213 export const toggleSession = (session: Session) =>
214     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
215         let s = { ...session };
216
217         if (session.loggedIn) {
218             s.loggedIn = false;
219         } else {
220             const sessions = getState().auth.sessions;
221             const activeSession = getActiveSession(sessions);
222             if (activeSession) {
223                 s = await dispatch<any>(validateSession(s, activeSession)) as Session;
224             }
225         }
226
227         dispatch(authActions.UPDATE_SESSION(s));
228         services.authService.saveSessions(getState().auth.sessions);
229     };
230
231 export const initSessions = (authService: AuthService, config: Config, user: User) =>
232     (dispatch: Dispatch<any>) => {
233         const sessions = authService.buildSessions(config, user);
234         authService.saveSessions(sessions);
235         dispatch(authActions.SET_SESSIONS(sessions));
236         dispatch(validateSessions());
237     };
238
239 export const loadSiteManagerPanel = () =>
240     async (dispatch: Dispatch<any>) => {
241         try {
242             dispatch(setBreadcrumbs([{ label: 'Site Manager' }]));
243             dispatch(validateSessions());
244         } catch (e) {
245             return;
246         }
247     };