aboutsummaryrefslogtreecommitdiff
path: root/lib/vnlib.browser/src/social/index.ts
blob: d4f9f2a2ce47e878f72613f8e6ca175f67012a46 (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
import { find, isEqual, map } from "lodash-es";
import { get } from "@vueuse/core";
import { MaybeRef } from "vue";
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';

export interface OAuthMethod{
    /**
     * Gets the url to the login endpoint for this method
     */
    readonly Id: 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) => 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 }
}

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;
}

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

    const cookieName = 'active-social-login';

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

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

    const getNonceQuery = () => new URLSearchParams(window.location.search).get('nonce');
    const getResultQuery = () => new URLSearchParams(window.location.search).get('result');
    const selectMethodForCurrentUrl = () => find(methods, method => {
        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);
    })

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

    const beginLoginFlow = async (method: T): Promise<void> => {
        //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
    }

    const completeLogin = async () => {

        //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.')
        }

        const method = selectMethodForCurrentUrl();

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

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

        const loginUrl = method.loginUrl();

        //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>(loginUrl, { ...login, nonce })

        //Verify result
        data.getResultOrThrow()
        
        //Complete login authorization
        await login.finalize(data);

        //Signal the method that the login was successful
        if(method.onSuccessfulLogin){
            method.onSuccessfulLogin();
        }

        //Set the cookie to the method id
        c.set(cookieName, method.Id, { path: loginUrl });
    }

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

        //see if any methods are active
        const method = getActiveMethod();

        if(!method){
            return false;
        }

        /**
         * 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 true;
        }

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

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

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

        //Signal the method that the logout was successful
        if (method.onSuccessfulLogout) {
            method.onSuccessfulLogout(data);
        }

        return true;
    }

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

/**
 * Creates a new simple social OAuth method used for login
 * @example
 * const google = createSocialMethod('google', 'https://accounts.google.com/o/oauth2/v2/auth')
 * const facebook = createSocialMethod('facebook', 'https://www.facebook.com/v2.10/dialog/oauth')
 */
export const createSocialMethod = (id: string, path: MaybeRef<string>): OAuthMethod => {
    return{
        Id: id,
        loginUrl: () => get(path),
    }
}

/**
 * Creates social OAuth methods from the given portals (usually captured from the server)
 */
export const fromPortals = (portals: SocialOAuthPortal[]): OAuthMethod[] => {
    return map(portals, p => {
        const method = createSocialMethod(p.id, p.login);

        //If a logout url is defined, then add it to the method
        if(p.logout){
            method.getLogoutData = () => ({ url: p.logout!, args: {} })
        }
        return method;
    })
}