From 1a82a909c5c4d0262d69a8a543e902ff6533a4b2 Mon Sep 17 00:00:00 2001 From: vnugent Date: Mon, 1 Jan 2024 10:56:02 -0500 Subject: swallow vnlib.browser --- lib/vnlib.browser/src/social/index.ts | 232 ++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 lib/vnlib.browser/src/social/index.ts (limited to 'lib/vnlib.browser/src/social/index.ts') diff --git a/lib/vnlib.browser/src/social/index.ts b/lib/vnlib.browser/src/social/index.ts new file mode 100644 index 0000000..a164eb8 --- /dev/null +++ b/lib/vnlib.browser/src/social/index.ts @@ -0,0 +1,232 @@ +import { find, isEqual } 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{ + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +/** + * Creates a new social login api for the given methods + */ +export const useSocialOauthLogin = (methods: T[], axiosConfig?: Partial): SocialLoginApi =>{ + + 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, { path: '/', 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 => { + //Prepare the login claim` + const claim = await prepareLogin() + const { data } = await axios.put>(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'); + } + + //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(method.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); + } + + const logout = async (): Promise => { + 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): OAuthMethod => { + return{ + Id: id, + loginUrl: () => get(path), + } +} \ No newline at end of file -- cgit