aboutsummaryrefslogtreecommitdiff
path: root/lib/vnlib.browser/src/social/index.ts
blob: 7d80687d25b41428a2552aa02fb6c895e717d947 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
import { find, first, isArray, isEqual, map } from "lodash-es";
import { Mutable, get } from "@vueuse/core";
import Cookies from "universal-cookie";
import { useUser } from "../user";
import { useAxios } from "../axios";
import { useSession, type ITokenResponse } from "../session";
import { type WebMessage } from "../types";
import { type AxiosRequestConfig } from "axios";

export type SocialServerSetQuery = 'invalid' | 'expired' | 'authorized';

/**
 * A continuation function that is called after a successful logout
 */
export type SocialLogoutContinuation = () => Promise<void>



export interface SocialLoginApi<T>{
    /**
     * The collection of registred authentication methods
     */
    readonly methods: T[]
    /**
     * Begins an OAuth2 social web authentication flow against the server
     * handling encryption and redirection of the browser
     * @param method The desired method to use for login
     */
    beginLoginFlow(method: T): Promise<void>;
    /**
     * Completes a login flow if authorized, otherwise throws an error
     * with the message from the server
     * @returns A promise that resolves when the login is complete 
     */
    completeLogin(): Promise<void>;
    /**
     * Logs out of the current session
     * @returns A promise that resolves to true if the logout could be handled by 
     * the current method, otherwise false
     */
    logout(): Promise<boolean>;
    /**
     * Gets the active method for the current session if the 
     * user is logged in using a social login method that is defined
     * in the methods collection
     */
    getActiveMethod(): T | undefined;
}

/**
 * A social OAuth portal that defines a usable server 
 * enabled authentication method
 */
export interface SocialOAuthPortal {
    readonly id: string;
    readonly login: string;
    readonly logout?: string;
    readonly icon?: string;
}

/**
 * An social OAuth2 authentication method that can be used to
 * authenticate against a server for external connections 
 */
export interface OAuthMethod {
    /**
     * The unique id of the method
     */
    readonly id: string;
    /**
     * Optional bas64encoded icon image url for the method
     */
    readonly icon?: string;
    /**
     * Determines if the current flow is active for this method
     */
    isActiveLogin(): boolean
    /**
     * Begins the login flow for this method
     */
    beginLoginFlow(): Promise<void>
    /**
     * Completes the login flow for this method
     */
    completeLogin(): Promise<void>
    /**
     * Logs out of the current session
     */
    logout(): Promise<SocialLogoutContinuation | void>
}

export interface SocialOauthMethod {
    /**
     * Gets the url to the login endpoint for this method
     */
    readonly id: string
    /**
     * Optional bas64encoded icon image url for the method
     */
    readonly icon?: string
    /**
     * The endpoint to submit the authentication request to
     */
    loginUrl(): string
    /**
     * Called when the login to this method was successful
     */
    onSuccessfulLogin?: () => void
    /**
     * Called when the logout to this method was successful
     */
    onSuccessfulLogout?: (responseData: unknown) => SocialLogoutContinuation | void
    /**
    * Gets the data to send to the logout endpoint, if this method
    * is undefined, then the logout will be handled by the normal user logout
    */
    getLogoutData?: () => { readonly url: string; readonly args: unknown }
}


interface SocialLogoutResult{
    readonly url: string | undefined;
}

/**
 * Creates a new social login api for the given methods
 */
export const useOauthLogin = <T extends OAuthMethod>(methods: T[]): SocialLoginApi<T> => {

    const cookieName = 'active-social-login';

    const { loggedIn } = useSession();

    //A cookie will hold the status of the current login method
    const c = new Cookies(null, { sameSite: 'strict', httpOnly: false });

    const getActiveMethod = (): T | undefined => {
        const methodName = c.get(cookieName)
        return find(methods, method => isEqual(method.id, methodName))
    }

    const beginLoginFlow =  (method: T): Promise<void> => {
        return method.beginLoginFlow()
    }

    const completeLogin = async () => {

        const method = find(methods, method => method.isActiveLogin());

        if (!method) {
            throw new Error('The current url is not a valid social login url');
        }

        await method.completeLogin();

        //Set the cookie to the method id
        c.set(cookieName, method.id);
    }

    const logout = async (): Promise<boolean> => {
        if (!get(loggedIn)) {
            return false;
        }

        //see if any methods are active, then call logout on the active method
        const method = getActiveMethod();

        if(!method){
           return false;
        }

        const result = await method.logout();

        //clear cookie on success
        c.remove(cookieName);

        if (result) {
            await result();
        }

        return true;
    }

    return {
        beginLoginFlow,
        completeLogin,
        getActiveMethod,
        logout,
        methods
    }
}

/**
 * Creates a new oauth2 login api for the given methods
 */
export const fromSocialConnections = <T extends SocialOauthMethod>(methods: T[], axiosConfig?: Partial<AxiosRequestConfig>): OAuthMethod[] =>{

    const { KeyStore } = useSession();
    const { prepareLogin, logout:userLogout } = useUser();
    const axios = useAxios(axiosConfig);

    const getNonceQuery = () => new URLSearchParams(window.location.search).get('nonce');
    const getResultQuery = () => new URLSearchParams(window.location.search).get('result');

    const checkForValidResult = () => {
        //Get auth result from query params
        const result = getResultQuery();
        switch (result) {
            case 'invalid':
                throw new Error('The request was invalid, and you could not be logged in. Please try again.');
            case 'expired':
                throw new Error('The request has expired. Please try again.');

            //Continue with login
            case 'authorized':
                break;

            default:
                throw new Error('There was an error processing the login request. Please try again.')
        }
    }

    return map(methods, method => {
        return{
            id: method.id,
            icon: method.icon,

            async beginLoginFlow() {
                //Prepare the login claim`
                const claim = await prepareLogin()
                const { data } = await axios.put<WebMessage<string>>(method.loginUrl(), claim)
                const encDat = data.getResultOrThrow()
                // Decrypt the result which should be a redirect url
                const result = await KeyStore.decryptDataAsync(encDat)
                // get utf8 text
                const text = new TextDecoder('utf-8').decode(result)
                // Recover url
                const redirect = new URL(text)
                // Force https
                redirect.protocol = 'https:'
                // redirect to the url
                window.location.href = redirect.href
            },
            async completeLogin() {
                checkForValidResult();

                //Recover the nonce from query params
                const nonce = getNonceQuery();
                if (!nonce) {
                    throw new Error('The current session has not been initialized for social login');
                }

                //Prepare the session for a new login
                const login = await prepareLogin();

                //Send a post request to the endpoint to complete the login and pass the nonce argument
                const { data } = await axios.post<ITokenResponse>(method.loginUrl(), { ...login, nonce })

                //Verify result
                data.getResultOrThrow()

                //Complete login authorization
                await login.finalize(data);
            },
            isActiveLogin() {
                const loginUrl = method.loginUrl();
                //Check for absolute url, then check if the path is the same
                if (loginUrl.startsWith('http')) {
                    const asUrl = new URL(loginUrl);
                    return isEqual(asUrl.pathname, window.location.pathname);
                }
                //Relative url
                return isEqual(loginUrl, window.location.pathname);
            },
            async logout() {
                /**
                 * If no logout data method is defined, then the logout 
                 * is handled by a normal account logout
                 */
                if (!method.getLogoutData) {
                    //Normal user logout
                    const result = await userLogout();

                    if (method.onSuccessfulLogout) {
                        method.onSuccessfulLogout(result);
                    }

                    return;
                }

                const { url, args } = method.getLogoutData();

                //Exec logout post request against the url
                const { data } = await axios.post(url, args);

                //Signal the method that the logout was successful
                return method.onSuccessfulLogout ? method.onSuccessfulLogout(data) : undefined; 
            },
        } as OAuthMethod
    });
}

/**
 * Adds a default logout function to the social login api that will
 * call the user supplied logout function if the social logout does not
 * have a registered logout method
 */
export const useSocialDefaultLogout = <T>(socialOauth: SocialLoginApi<T>, logout: () => Promise<unknown>): SocialLoginApi<T> => {
    //Store old logout function for later use
    const logoutFunc = socialOauth.logout;

    (socialOauth as Mutable<SocialLoginApi<T>>).logout = async (): Promise<boolean> => {
        //If no logout was handled by social, fall back to user supplied logout
        if (await logoutFunc() === false) {
            await logout()
        }
        return true;
    }

    return socialOauth;
}

export const fromSocialPortals = (portals: SocialOAuthPortal[]): SocialOauthMethod[] => {
    return map(portals, p => {
        return {
            id: p.id,
            icon: p.icon,
            loginUrl : () => p.login,
            //Get the logout data from the server
            getLogoutData: () => ({ url: p.logout!, args: {}}),
            //Redirect to the logout url returned by the server
            onSuccessfulLogout: (data: SocialLogoutResult) => {
                if (data.url) {
                    return () => {
                        window.location.assign(data.url!);
                        return Promise.resolve();
                    }
                }
            },
            onSuccessfulLogin: () => {}
        } as SocialOauthMethod
    })
}

export const fetchSocialPortals = async (portalEndpoint: string, axiosConfig?: Partial<AxiosRequestConfig>): Promise<SocialOAuthPortal[]> => {
    //Get axios instance
    const axios = useAxios(axiosConfig)

    //Get all enabled portals
    const { data } = await axios.get<SocialOAuthPortal[]>(portalEndpoint);

    /**
     * See if the response was a json array.
     * 
     * Sometimes axios will parse HTML as json and still
     * return an object array, so if its an array, then
     * verify that at least one element is a valid portal
     */
    if (isArray(data)) {
        if(data.length === 0){
            return [];
        }
       
        return first(data)?.id ? data : [];
    }

    throw new Error('The response from the server was not a valid json array');
}