diff options
Diffstat (limited to 'lib/vnlib.browser')
35 files changed, 3391 insertions, 0 deletions
diff --git a/lib/vnlib.browser/LICENSE.txt b/lib/vnlib.browser/LICENSE.txt new file mode 100644 index 0000000..581ced4 --- /dev/null +++ b/lib/vnlib.browser/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Vaughn Nugent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
\ No newline at end of file diff --git a/lib/vnlib.browser/README.md b/lib/vnlib.browser/README.md new file mode 100644 index 0000000..7182de6 --- /dev/null +++ b/lib/vnlib.browser/README.md @@ -0,0 +1,11 @@ +# @vnuge/vnlib.browser + +This repo contains the client side JavaScript library for interacting with the VNLib.Plugins.Essentials.Accounts user account api and security system. This library was also configured for use in web-extension context. + +## Getting started +You can find a startup and installation guide on my website + +[Docs and guides](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_vnlib.browser) + +## License +All source files in this repository is licensed under the MIT License. See the LICENSE files for more information.
\ No newline at end of file diff --git a/lib/vnlib.browser/Taskfile.yaml b/lib/vnlib.browser/Taskfile.yaml new file mode 100644 index 0000000..bdac379 --- /dev/null +++ b/lib/vnlib.browser/Taskfile.yaml @@ -0,0 +1,36 @@ +# https://taskfile.dev + +#Called by the vnbuild system to produce builds for my website +#https://www.vaughnnugent.com/resources/software + + +#this file must be in the same directory as the solution file + +version: '3' + +tasks: + +#called by build pipeline to build module + build: + cmds: + - echo "building module {{.MODULE_NAME}}" + + #install dependencies and build + - npm install + - npm run build + + postbuild_success: + cmds: + - powershell -Command "mkdir bin -Force" + #tgz the dist folder + - tar --exclude="./node_modules" --exclude="./src" --exclude="./.git" --exclude="./bin" --exclude=".gitignore" --exclude="*.yaml" --exclude="*.yml" -czf bin/release.tgz . + + +#called by build pipeline to clean module + clean: + ignore_error: true + cmds: + #delete dist folder + - cmd: powershell -Command "Remove-Item -Recurse node_modules" + - cmd: powershell -Command "Remove-Item -Recurse dist" + - cmd: powershell -Command "Remove-Item -Recurse -Force bin"
\ No newline at end of file diff --git a/lib/vnlib.browser/src/axios/index.ts b/lib/vnlib.browser/src/axios/index.ts new file mode 100644 index 0000000..644011e --- /dev/null +++ b/lib/vnlib.browser/src/axios/index.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { computed, MaybeRef, type Ref } from 'vue' +import { cloneDeep, merge, isObjectLike, defaultTo } from 'lodash-es' +import axios, { type Axios, type AxiosRequestConfig, type AxiosResponse } from 'axios' +import { get, toReactive } from '@vueuse/core'; +import { useSession, type ISession } from '../session' +import { getGlobalStateInternal } from '../globalState'; + +const configureAxiosInternal = (instance: Axios, session: ISession, tokenHeader: Ref<string | undefined>) => { + + const { loggedIn, generateOneTimeToken } = session; + + //Add request interceptor to add the token to the request + instance.interceptors.request.use(async (config) => { + + //Get the current global config/token header value + const tokenHeaderValue = get(tokenHeader); + + // See if the current session is logged in + if (tokenHeaderValue && loggedIn.value) { + // Get an otp for the request + config.headers[tokenHeaderValue] = await generateOneTimeToken() + } + // Return the config + return config + }, function (error) { + // Do something with request error + return Promise.reject(error) + }) + + //Add response interceptor to add a function to the response to get the result or throw an error to match the WebMessage server message + instance.interceptors.response.use((response: AxiosResponse) => { + + //Add a function to the response to get the result or throw an error + if (isObjectLike(response.data)) { + response.data.getResultOrThrow = () => { + if (response.data.success) { + return response.data.result; + } else { + //Throw in apicall format to catch in the catch block + throw { response }; + } + } + } + return response; + }) + + return instance; +} + +/** + * Gets a reactive axios instance with the default configuration + * @param config Optional Axios instance configuration to apply, will be merged with the default config + * @returns A reactive ref to an axios instance + */ +export const useAxiosInternal = (() => { + + //Get the session and utils + const { axios: _axiosConfig } = getGlobalStateInternal(); + + const tokenHeader = computed(() => defaultTo(_axiosConfig.value.tokenHeader, '')); + const session = useSession(); + + return (config?: MaybeRef<AxiosRequestConfig | undefined | null>): Readonly<Ref<Axios>> => { + + /** + * Computed config, merges the default config with the passed config. When + * the fallback config is updated, it will compute the merged config + */ + const mergedConfig = config ? + computed(() => merge(cloneDeep(_axiosConfig.value), get(config))) + : _axiosConfig + + /** + * Computes a new axios insance when the config changes + */ + const computedAxios = computed(() => { + const instance = axios.create(mergedConfig.value); + return configureAxiosInternal(instance, session, tokenHeader); + }); + + return computedAxios; + } +})(); + +/** + * Gets a reactive axios instance that merges the supplied config with the global config + * @param config Optional Axios instance configuration to apply, will be merged with the default config + * @returns The axios instance + */ +export const useAxios = (config: MaybeRef<AxiosRequestConfig | undefined | null>): Axios => { + + const axiosRef = useAxiosInternal(config); + + /** + * Return a reactive axios instance. When updates are made to the config, + * the instance will be updated without the caller needing to re-request it. + */ + return toReactive(axiosRef); +} diff --git a/lib/vnlib.browser/src/binhelpers.ts b/lib/vnlib.browser/src/binhelpers.ts new file mode 100644 index 0000000..c6ad274 --- /dev/null +++ b/lib/vnlib.browser/src/binhelpers.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +export const LongToArray = function (long : number) { + // Empty array + const byteArray = Array(8).fill(0) + for (let index = 0; index < 8; index++) { + const byte = long & 0xff + byteArray[index] = byte + long = (long - byte) / 256 + } + return byteArray +} + +export const IntToArray = function(int : number) { + // Empty array + const byteArray = Array(4).fill(0) + for (let index = 0; index < 4; index++) { + const byte = int & 0xff + byteArray[index] = byte + int = (int - byte) / 256 + } + return byteArray +} + +export const Base64ToArray = function (b64string : string) : Array<number> { + // Recover the encoded data + const decData = atob(b64string) + // Convert to array + return Array.from(decData, c => c.charCodeAt(0)) +} + +export const Base64ToUint8Array = function (b64string : string) : Uint8Array { + // Recover the encoded data + const decData = atob(b64string) + // Convert to array + return Uint8Array.from(decData, c => c.charCodeAt(0)) +} + +export const Utf8StringToBuffer = function (str : string) : Array<number> { + // encode the string to utf8 binary + const enc = new TextEncoder().encode(str) + return Array.from(enc); +} + +export const ArrayBuffToBase64 = function(e : ArrayBuffer) : string { + const arr = Array.from(new Uint8Array(e)) + return btoa(String.fromCharCode.apply(null, arr)) +} + +export const ArrayToHexString = function(buffer : Array<number> | ArrayBuffer) : string { + return Array.prototype.map.call(buffer, function (byte : number) { + return ('0' + (byte & 0xFF).toString(16)).slice(-2) + }).join('') +} diff --git a/lib/vnlib.browser/src/globalState/index.ts b/lib/vnlib.browser/src/globalState/index.ts new file mode 100644 index 0000000..4cdbea3 --- /dev/null +++ b/lib/vnlib.browser/src/globalState/index.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { merge } from "lodash-es"; +import { ref, type ToRefs, type Ref } from "vue"; +import { toRefs, type StorageLike, set } from "@vueuse/core"; +import { toReactive, createStorageRef } from "./storage"; +import type { SessionConfig } from "../session"; +import type { UserConfig } from "../user"; +import type { AxiosRequestConfig } from "axios"; + +export interface GlobalSessionConfig extends SessionConfig { + readonly cookiesEnabled: boolean; + readonly loginCookieName: string; +} + +export interface GlobalAxiosConfig extends AxiosRequestConfig { + tokenHeader: string; +} + +export interface GlobalApiConfig { + readonly session: GlobalSessionConfig; + readonly axios: GlobalAxiosConfig; + readonly user: UserConfig; + readonly storage: StorageLike; +} + +export interface GlobalConfigUpdate { + readonly session?: Partial<GlobalSessionConfig>; + readonly axios?: Partial<GlobalAxiosConfig>; + readonly user?: Partial<UserConfig>; + readonly storage?: StorageLike; +} + +export enum StorageKey { + Session = '_vn-session', + Keys = "_vn-keys", + User = '_vn-user' +} + + +/** + * Gets the default/fallback axios configuration + * @returns The default axios configuration + */ +const getDefaultAxiosConfig = (): GlobalAxiosConfig => { + return { + baseURL: '/', + timeout: 60 * 1000, + withCredentials: false, + tokenHeader: 'X-Web-Token' + } +} + +/** + * Gets the default/fallback session configuration + * @returns The default session configuration + */ +const getDefaultSessionConfig = (): GlobalSessionConfig & SessionConfig => { + return { + browserIdSize: 32, + signatureAlgorithm: 'HS256', + + cookiesEnabled: navigator?.cookieEnabled === true, + loginCookieName: 'li', + + keyAlgorithm: { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + } as RsaHashedKeyAlgorithm, + } +} + +/** + * Get the default/fallback user configuration + * @returns The default user configuration + */ +const getDefaultUserConfig = (): UserConfig => { + return { + accountBasePath: '/account', + } +}; + +const _globalState = ref<GlobalApiConfig>({ + axios: getDefaultAxiosConfig(), + session: getDefaultSessionConfig(), + user: getDefaultUserConfig(), + storage: localStorage +}); + +//Get refs to the state +const _refs = toRefs(_globalState); + +//Store reactive storage +const rStorage = toReactive(_refs.storage); + +export const getGlobalStateInternal = (): Readonly<ToRefs<GlobalApiConfig>> => _refs + +/** + * Gets a reactive storage slot that will work from the + * global configuration storage + */ +export const createStorageSlot = <T>(key: StorageKey, defaultValue: T): Ref<T> => createStorageRef(rStorage, key, defaultValue); + +/** + * Sets the global api configuration + * @param config The new configuration + */ +export const setApiConfigInternal = (config: GlobalConfigUpdate): void => { + + //merge with defaults + const newConfig = { + axios: merge(getDefaultAxiosConfig(), config.axios), + session: merge(getDefaultSessionConfig(), config.session), + user: merge(getDefaultUserConfig(), config.user), + storage: config.storage + } + + //Update the global state + set(_globalState, newConfig) +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/globalState/storage.ts b/lib/vnlib.browser/src/globalState/storage.ts new file mode 100644 index 0000000..b3fb1bb --- /dev/null +++ b/lib/vnlib.browser/src/globalState/storage.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { ref, type Ref } from 'vue'; +import { defer, isEqual } from 'lodash-es'; +import { watchDebounced, type StorageLike } from '@vueuse/core' + +export interface ReactiveStorageLike extends StorageLike { + onStorageChanged: (key: string, callback: () => void) => void; +} + +export const toReactive = (storage: Ref<StorageLike | undefined>): ReactiveStorageLike => { + + const getItem = (key: string): string | null => storage.value?.getItem(key) ?? null; + const setItem = (key: string, value: string): void => storage.value?.setItem(key, value); + const removeItem = (key: string): void => storage.value?.removeItem(key); + + const onStorageChanged = (key: string, callback: () => void) => { + //Listen for storage changes on the window and invoke the callback for all changes + window?.addEventListener('storage', ev => ev.key === key ? callback() : null); + + //Also register watcher for storage change events + watchDebounced(storage, callback, { debounce: 100, immediate:false }) + + //intial update + callback(); + } + + return { + getItem, + setItem, + removeItem, + onStorageChanged + } +} + +export const createStorageRef = <T>(backend: ReactiveStorageLike, key: string, defaultValue: T): Ref<T> => { + //reactive storage element + const storage = ref<T>(defaultValue); + + //Recovers data from storage + const onUpdate = () => { + const string = backend.getItem(key); + storage.value = JSON.parse(string || "{}"); + } + + //Watch storage changes + backend.onStorageChanged(key, onUpdate); + + //Watch for reactive changes and write to storage + watchDebounced(storage, (value) => defer(() => { + //Convert to string and store + const string = JSON.stringify(value); + const oldValue = backend.getItem(key); + //Only write if the value has changed + if (isEqual(string, oldValue)) { + return; + } + //Write to storage + backend.setItem(key, string); + }), { deep: true, debounce: 100 }) + + return storage as Ref<T>; +} diff --git a/lib/vnlib.browser/src/helpers/apiCall.ts b/lib/vnlib.browser/src/helpers/apiCall.ts new file mode 100644 index 0000000..b98fecb --- /dev/null +++ b/lib/vnlib.browser/src/helpers/apiCall.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { type MaybeRef, type Ref } from 'vue' +import { AxiosError, type Axios } from 'axios'; +import { defaultTo, isArray, isNil, isEqual } from 'lodash-es'; +import { get, useConfirmDialog } from '@vueuse/core'; +import { useFormToaster, useToaster } from '../toast'; +import { useWait } from './wait'; +import { useAxiosInternal } from '../axios'; +import type { IErrorNotifier, CombinedToaster } from '../toast'; + +export interface IApiHandle<T> { + /** + * Called to get the object to pass to apiCall is invoked + */ + getCallbackObject(): T; + + /** + * Called to get the notifier to use for the api call + */ + getNotifier(): IErrorNotifier; + + /** + * Called to set the waiting flag + */ + setWaiting: (waiting: boolean) => void; +} + +export interface IApiPassThrough { + readonly axios: Axios; + readonly toaster: CombinedToaster; +} + +export interface IElevatedCallPassThrough extends IApiPassThrough { + readonly password: string; +} + +export interface ApiCall<T> { + <TR>(callback: (data: T) => Promise<TR | undefined>): Promise<TR | undefined>; +} + +export type CustomMessageHandler = (message: string) => void; + +const useApiCallInternal = <T>(args: IApiHandle<T>): ApiCall<T> => { + + /** + * Provides a wrapper method for making remote api calls to a server + * while capturing context and errors and common api arguments. + * @param {*} callback The method to call within api request context + * @returns A promise that resolves to the result of the async function + */ + return async <TR>(callback: (data: T) => Promise<TR | undefined>): Promise<TR | undefined> => { + const notifier = args.getNotifier(); + + // Set the waiting flag + args.setWaiting(true); + + try { + //Close the current toast value + notifier.close(); + + const obj = args.getCallbackObject(); + + //Exec the async function + return await callback(obj); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (errMsg: any) { + console.error(errMsg) + // See if the error has an axios response + if (isNil(errMsg.response)) { + if (errMsg.message === 'Network Error') { + notifier.notifyError('Please check your internet connection') + } else { + notifier.notifyError('An unknown error occured') + } + return + } + // Axios error message + const response = errMsg.response + const errors = response?.data?.errors + const hasErrors = isArray(errors) && errors.length > 0 + + const SetMessageWithDefault = (message: string) => { + if (hasErrors) { + const title = 'Please verify your ' + defaultTo(errors[0].property, 'form') + notifier.notifyError(title, errors[0].message) + } else { + notifier.notifyError(defaultTo(response?.data?.result, message)) + } + } + + switch (response.status) { + case 200: + SetMessageWithDefault('') + break + case 400: + SetMessageWithDefault('Bad Request') + break + case 422: + SetMessageWithDefault('The server did not accept the request') + break + case 401: + SetMessageWithDefault('You are not logged in.') + break + case 403: + SetMessageWithDefault('Please clear you cookies/cache and try again') + break + case 404: + SetMessageWithDefault('The requested resource was not found') + break + case 409: + SetMessageWithDefault('Please clear you cookies/cache and try again') + break + case 410: + SetMessageWithDefault('The requested resource has expired') + break + case 423: + SetMessageWithDefault('The requested resource is locked') + break + case 429: + SetMessageWithDefault('You have made too many requests, please try again later') + break + case 500: + SetMessageWithDefault('There was an error processing your request') + break + default: + SetMessageWithDefault('An unknown error occured') + break + } + } finally { + // Clear the waiting flag + args.setWaiting(false); + } + } +} + +const creatApiHandle = (notifier: MaybeRef<IErrorNotifier>, axios: Ref<Axios>): IApiHandle<IApiPassThrough> => { + + const toaster = useToaster(); + const { setWaiting } = useWait(); + + const getCallbackObject = (): IApiPassThrough => ({ axios: get(axios), toaster }) + const getNotifier = (): IErrorNotifier => get(notifier); + + return { getCallbackObject, getNotifier, setWaiting } +} + +/** + * Provides a wrapper method for making remote api calls to a server + * while capturing context and errors and common api arguments. + * @param {*} asyncFunc The method to call within api request context + * @returns A promise that resolves to the result of the async function + */ +export const apiCall = (() =>{ + + const axios = useAxiosInternal(null); + const errorNotifier = useFormToaster(); + + //Create the api call handle + const handle = creatApiHandle(errorNotifier, axios); + //Confiugre the api call to use global configuration + return useApiCallInternal(handle); +})(); + +/** + * Customizes the api call to use a custom error message + * @param msg The message to display when an error occurs + * @returns {Object} The api call object {apiCall: Promise } + */ +export const configureApiCall = (msg: CustomMessageHandler): { apiCall: ApiCall<IApiPassThrough> } =>{ + + const notifier = ((): IErrorNotifier => { + return{ + notifyError: (t: string, m?: string) => { + msg(t); + return m; + }, + close(id: string) { + msg('') + return id; + }, + } + })() + + const axios = useAxiosInternal(null); + + //Create custom api handle + const handle = creatApiHandle(notifier, axios); + + //Confiugre the api call to use global configuration + const apiCall = useApiCallInternal(handle); + return { apiCall } +} + +/** + * Gets the shared password prompt object and the elevated api call method handler + * to allow for elevated api calls that require a password. + * @returns {Object} The password prompt configuration object, and the elevated api call method + */ +export const usePassConfirm = (() => { + + //Shared confirm object + const confirm = useConfirmDialog(); + + /** + * Displays the password prompt and executes the api call with the password + * captured from the prompt. If the api call returns a 401 error, the password + * prompt is re-displayed and the server error message is displayed in the form + * error toaster. + * @param callback The async callback method that invokes the elevated api call. + * @returns A promise that resolves to the result of the async function + */ + const elevatedApiCall = <T>(callback: (api: IElevatedCallPassThrough) => Promise<T>): Promise<T | undefined> => { + //Invoke api call method but handle 401 errors by re-displaying the password prompt + return apiCall<T>(async (api: IApiPassThrough) : Promise<T | undefined> => { + // eslint-disable-next-line no-constant-condition + while (1) { + + //Display the password prompt + const { data, isCanceled } = await confirm.reveal() + + if (isCanceled) { + break; + } + + try { + //Execute the api call with prompt response + return await callback({...api, ...data }); + } + //Catch 401 errors and re-display the password prompt, otherwise throw the error + catch (err) { + if(!(err instanceof AxiosError)){ + throw err; + } + + const { response } = err; + + if(isNil(response)){ + throw err; + } + + //Check status code, if 401, re-display the password prompt + if (!isEqual(response?.status, 401)) { + throw err; + } + + //Display the error message + api.toaster.form.error({ title: response.data.result }); + + //Re-display the password prompt + } + } + }) + } + + //Pass through confirm object and elevated api call + return () => ({ ...confirm, elevatedApiCall }) +})();
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/autoHeartbeat.ts b/lib/vnlib.browser/src/helpers/autoHeartbeat.ts new file mode 100644 index 0000000..459a33a --- /dev/null +++ b/lib/vnlib.browser/src/helpers/autoHeartbeat.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { isRef, type Ref } from "vue"; +import { useIntervalFn, syncRef, get, type MaybeRef } from "@vueuse/core"; +import { useUser } from "../user"; +import { useSession } from "../session"; + +export interface IAutoHeartbeatControls { + /** + * The current state of the heartbeat interval + */ + enabled: Ref<boolean>; + /** + * Enables the hearbeat interval if configured + */ + enable: () => void; + /** + * Disables the heartbeat interval + */ + disable: () => void; +} + +/** +* Configures shared controls for the heartbeat interval +* @param enabled The a reactive value that may be used to enable/disable the heartbeat interval +*/ +export const useAutoHeartbeat = (interval: Readonly<MaybeRef<number>>, enabled: MaybeRef<boolean>): IAutoHeartbeatControls =>{ + + //Get heartbeat method to invoke when the timer fires + const { heartbeat } = useUser(); + const { loggedIn } = useSession(); + + //Setup the automatic heartbeat interval + const { isActive, pause, resume } = useIntervalFn(() => loggedIn.value ? heartbeat() : null, interval); + + //Sync the enabled ref with the active state if it is a ref + if(isRef(enabled)){ + //only sync from caller value to ref + syncRef(enabled, isActive, { direction: 'ltr' }) + } + + //Update timer immediately + get(enabled) ? resume() : pause(); + + return { enabled:isActive, enable:resume, disable:pause } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/confirm.ts b/lib/vnlib.browser/src/helpers/confirm.ts new file mode 100644 index 0000000..77b92e0 --- /dev/null +++ b/lib/vnlib.browser/src/helpers/confirm.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { useConfirmDialog } from "@vueuse/core" + +/** + * Gets the global confirm dialog interface for building application wide + * confirm dialog state + */ +export const useConfirm = (() => { + //Store a global confirm dialog singleton to share across components + const globalConfirm = useConfirmDialog() + return () => globalConfirm; +})()
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/envSize.ts b/lib/vnlib.browser/src/helpers/envSize.ts new file mode 100644 index 0000000..e078e5b --- /dev/null +++ b/lib/vnlib.browser/src/helpers/envSize.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { toSafeInteger } from 'lodash-es'; +import { ref, computed } from "vue" +import { useElementSize } from '@vueuse/core' + +/** + * Setups a common state for the environment size + */ +export const useEnvSize = (() => { + //Element refs for app to watch + const header = ref(null); + const footer = ref(null); + const content = ref(null); + + //Setup reactive element sizes + const headerSize = useElementSize(header); + const footerSize = useElementSize(footer); + const contentSize = useElementSize(content); + + const footerHeight = computed(() => toSafeInteger(footerSize.height.value)); + const headerHeight = computed(() => toSafeInteger(headerSize.height.value)); + const contentHeight = computed(() => toSafeInteger(contentSize.height.value - headerSize.height.value - footerSize.height.value)); + + + return (exportElements?: boolean) => { + + return exportElements ? + { footerHeight, headerHeight, contentHeight, header, footer, content } : + { footerHeight, headerHeight, contentHeight } + } +})();
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/lastPage.ts b/lib/vnlib.browser/src/helpers/lastPage.ts new file mode 100644 index 0000000..8be2116 --- /dev/null +++ b/lib/vnlib.browser/src/helpers/lastPage.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { defaultTo } from "lodash-es"; +import { useRouter } from "vue-router" + +export interface ILastPage{ + /** + * Pushes the current page into the last-page stack + */ + push(): void; + /** + * Stores the current page and navigates to the desired route + * @param route The route to navigate to + */ + pushAndNavigate(route: object): void; + /** + * Navigates to the last page if it exists + */ + gotoLastPage(): void; +} + +export interface ILastPageStorage { + + /** + * Pushes the current page into the last-page stack + */ + push(route: string): void; + + /** + * Pops the last page from the last-page stack storage + * @returns {any} The last page route object stored + */ + pop(): string | null; +} + +const storageKey = "lastPage"; + +//Storage impl +const defaultStack = (): ILastPageStorage => { + const storage = sessionStorage; + + const push = (route: string) => { + //Serialize the route data and store it + storage?.setItem(storageKey, route); + } + + const pop = (): string | null => { + //Get the route data and deserialize it + const route = storage?.getItem(storageKey); + if (route) { + storage?.removeItem(storageKey); + return route; + } + return null; + } + return { push, pop } +} + +/** + * Gets the configuration for the last page the user was on + * when the page guard was called. This is used to return to the + * last page after login. + * @returns { gotoLastPage: Function } + */ +export const useLastPage = (storage?: ILastPageStorage): ILastPage => { + + //fallback to default storage + const _storage = defaultTo(storage, defaultStack()); + + //Get the current router instance + const router = useRouter(); + + //Store the current page to the last page stack + const push = () => _storage.push(router.currentRoute.value.fullPath); + + const pushAndNavigate = (route: object) => { + //Store the current page to the last page stack + push(); + //Navigate to the desired route + router.push(route); + }; + + const gotoLastPage = () => { + //Get the last stored page and navigate to it + const lp = _storage.pop(); + if (lp) { + router.push(lp); + } + }; + + return { push, pushAndNavigate, gotoLastPage } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/message.ts b/lib/vnlib.browser/src/helpers/message.ts new file mode 100644 index 0000000..3c79577 --- /dev/null +++ b/lib/vnlib.browser/src/helpers/message.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { defaultTo } from 'lodash-es'; +import { useFormToaster } from '../toast' +import type { IErrorNotifier } from "../toast"; + +/** + * The message handler interface + */ +export interface IMessageHandler { + setMessage(title: string, text?: string): void; + clearMessage(): void; + onInput(): void; +} + +/** + * Gets the message state subsystem for displaying errors + * to the user and clearing them + * @param notifier An optional notifier to use instead of the default + */ +export const useMessage = (notifier?: IErrorNotifier): IMessageHandler =>{ + + const _notifier = defaultTo(notifier, useFormToaster()) + + const clearMessage = (): void => _notifier.close() + + const setMessage = (title: string, text = ''): void => { + //Setting a null value will also clear the message + title ? _notifier.notifyError(title, text) : clearMessage(); + } + + const onInput = clearMessage; + + return { + setMessage, + clearMessage, + onInput + } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/helpers/pageGuard.ts b/lib/vnlib.browser/src/helpers/pageGuard.ts new file mode 100644 index 0000000..c3a767b --- /dev/null +++ b/lib/vnlib.browser/src/helpers/pageGuard.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { watch } from 'vue' +import { useSession } from "../session"; +import { useLastPage } from './lastPage'; +import type { ILastPageStorage } from "./lastPage"; + + +/** + * When called, configures the component to + * only be visible when the user is logged in. If the user is + * not logged in, the user is redirected to the login page. + * @remarks Once called, if the user is logged-in changes will be + * watch to redirect if the user becomes logged out. +*/ +export const usePageGuard = (loginRoute = { name: 'Login' }, lpStorage?: ILastPageStorage): void => { + //Get the session state + const session = useSession(); + + //Get last page controller, fall back to default session storage stack + const { pushAndNavigate } = useLastPage(lpStorage) + + // Initial check for logged in to guard the page + if (!session.loggedIn.value) { + //Store last route and redirect to login + pushAndNavigate(loginRoute); + } + + // setup watcher on session login value + // If the login value changes to false, redirect to login page + watch(session.loggedIn, value => value === false ? pushAndNavigate(loginRoute) : null) +} diff --git a/lib/vnlib.browser/src/helpers/serverObjectBuffer.ts b/lib/vnlib.browser/src/helpers/serverObjectBuffer.ts new file mode 100644 index 0000000..5595ec7 --- /dev/null +++ b/lib/vnlib.browser/src/helpers/serverObjectBuffer.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { clone, isEqual, assign } from 'lodash-es' +import { reactive, computed, type UnwrapNestedRefs, type Ref } from 'vue' + +/** + * Represents a server object that has been buffered for editing + * and can be reverted to the original state, or new state data + * applied from the server + */ +export interface ServerObjectBuffer<T> { + /** + * The data is a reactive clone of the initial data state. It is used to store the current state of the resource. + */ + readonly data: UnwrapNestedRefs<T>; + /** + * The buffer is a reactive clone of the initial data state. It is used to track changes. + */ + readonly buffer: UnwrapNestedRefs<T>; + /** + * A computed value that indicates if the buffer has been modified from the data + */ + readonly modified: Readonly<Ref<boolean>>; + /** + * Applies the new server response data to the resource data and reverts the buffer to the resource data + * @param newData The new server response data to apply to the buffer + */ + apply(newData: T): void; + /** + * Reverts the buffer to the resource data + */ + revert(): void; +} + +export type UpdateCallback<T, TResut> = (state: ServerObjectBuffer<T>) => Promise<TResut>; + +/** + * Represents a server object that has been buffered for editing + * and can be reverted to the original state or updated on the server + */ +export interface ServerDataBuffer<T, TResut> extends ServerObjectBuffer<T>{ + /** + * Pushes buffered changes to the server if configured + */ + update(): Promise<TResut>; +} + +export interface UseDataBuffer { + /** + * Configures a helper type that represents a data buffer that reflects the state of a resource + * on the server. This is useful for editing forms, where the user may want to revert their + * changes, or reload them from the server after an edit; + * @param initialData The intiial data to wrap in the buffer + * @returns + */ + <T extends object>(initialData: T): ServerObjectBuffer<T>; + /** + * Configures a helper type that represents a data buffer that reflects the state of a resource + * on the server. This is useful for editing forms, where the user may want to revert their + * changes, or reload them from the server after an edit; + * @param initialData + * @returns + */ + <T extends object, TState = unknown>(initialData: T, onUpdate: UpdateCallback<T, TState>): ServerDataBuffer<T, TState>; +} + + +const createObjectBuffer = <T extends object>(initialData: T): ServerObjectBuffer<T> => { + + /** + * The data is a reactive clone of the initial data state. It is used to store the current state of the resource. + */ + const data = reactive(clone(initialData || {})) + + /** + * The buffer is a reactive clone of the initial data state. It is used to track changes. + */ + const buffer = reactive(clone(initialData || {})) + + /** + * A computed value that indicates if the buffer has been modified from the data + * @returns {boolean} True if the buffer has been modified from the data + */ + const modified = computed(() => !isEqual(buffer, data)) + + /** + * Applies the new server response data to the resource data and reverts the buffer to the resource data + * @param newData The new server response data to apply to the buffer + */ + const apply = (newData: T): void => { + // Apply the new data to the buffer + assign(data, newData); + // Revert the buffer to the resource data + assign(buffer, data) + } + + /** + * Reverts the buffer to the resource data + */ + const revert = (): void => { + assign(buffer, data); + } + + return { data, buffer, modified, apply, revert } +} + +const createUpdatableBuffer = <T extends object, TState = unknown> (initialData: T, onUpdate: UpdateCallback<T, TState>): ServerDataBuffer<T, TState> => { + //Configure the initial data state and the buffer + + const state: ServerObjectBuffer<T> = createObjectBuffer(initialData) + + /** + * Pushes buffered changes to the server if configured + */ + const update = async (): Promise<TState> => { + return onUpdate ? onUpdate(state) : Promise.resolve(undefined as unknown as TState); + } + + return{ ...state, update } +} + +const _useDataBuffer = <T extends object, TState = unknown>(initialData: T, onUpdate?: UpdateCallback<T, TState>) +: ServerObjectBuffer<T> | ServerDataBuffer<T, TState> => { + + return onUpdate ? createUpdatableBuffer<T, TState>(initialData, onUpdate) : createObjectBuffer<T>(initialData) +} + +/** + * Cretes a buffer object that represents server data, tracks changes, + * can revert changes, and can optionally push buffered changes to the server + */ +export const useDataBuffer: UseDataBuffer = (_useDataBuffer as UseDataBuffer) diff --git a/lib/vnlib.browser/src/helpers/validation.ts b/lib/vnlib.browser/src/helpers/validation.ts new file mode 100644 index 0000000..e60563e --- /dev/null +++ b/lib/vnlib.browser/src/helpers/validation.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { defaultTo, first } from "lodash-es"; +import { get } from "@vueuse/core"; +import { ref, type MaybeRef } from "vue"; +import { useFormToaster } from "../toast"; +import type { IErrorNotifier } from "../toast"; + +/** + * Represents a validator that performs validation and returns a boolean + * along with an error message if the validation fails + */ +export interface IValidator { + /** + * Performs asynchronous validation and returns a boolean + * indicating if the validation was successful + */ + validate(): Promise<boolean>; + /** + * Returns the first error message in the validation list + */ + firstError(): Error; +} + +export interface ValidationWrapper { + /** + * Computes the validation of the wrapped validator and captures the results. + * If the validation fails, the first error message is displayed in a toast notification. + * @returns The result of the validation + */ + validate: () => Promise<boolean>; +} + +export interface VuelidateInstance { + $validate: () => Promise<boolean>; + $errors: Array<{ $message: string }>; +} + +//Wrapper around a Vuelidate validator +const createVuelidateWrapper = <T extends VuelidateInstance>(validator: Readonly<MaybeRef<T>>): IValidator => { + const validate = async (): Promise<boolean> => { + return get(validator).$validate(); + } + + const firstError = (): Error => { + const errs = get(validator).$errors; + return new Error(first(errs)?.$message); + } + + return { validate, firstError } +} + +const createValidator = <T extends IValidator>(validator: MaybeRef<T>, toaster: IErrorNotifier): ValidationWrapper => { + const validate = async (): Promise<boolean> => { + // Validate the form + const valid = await get(validator).validate(); + // If the form is no valid set the error message + if (!valid) { + const first = get(validator).firstError(); + // Set the error message to the first error in the form list + toaster.notifyError(first.message); + } + return valid + } + + return { validate } +} + +/** + * Wraps a validator with a toaster to display validation errors + * @param validator The validator to wrap + * @param toaster The toaster to use for validation errors + * @returns The validation toast wrapper + * @example returns { validate: Function<Promise<boolean>> } + * const { validate } = useValidationWrapper(validator, toaster) + * const result = await validate() + */ +export const useValidationWrapper = <T extends IValidator>(validator: Readonly<MaybeRef<T>>, toaster?: IErrorNotifier): ValidationWrapper => { + return createValidator(validator, toaster ?? useFormToaster()); +} + +/** + * Wraps a Vuelidate validator with a toaster to display validation errors + * @param validator The vuelidate instance to wrap + * @param toaster The toaster to use for validation errors + * @returns The validation toast wrapper + * @example returns { validate: Function<Promise<boolean>> } + * const { validate } = useValidationWrapper(validator, toaster) + * const result = await validate() + */ +export const useVuelidateWrapper = <T extends VuelidateInstance>(v$: Readonly<MaybeRef<T>>, toaster?: IErrorNotifier) => { + const wrapper = createVuelidateWrapper(v$); + //Vuelidate class wrapper around the validator + const validator = ref(wrapper); + return createValidator(validator, defaultTo(toaster, useFormToaster())); +} diff --git a/lib/vnlib.browser/src/helpers/wait.ts b/lib/vnlib.browser/src/helpers/wait.ts new file mode 100644 index 0000000..0443199 --- /dev/null +++ b/lib/vnlib.browser/src/helpers/wait.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { set } from "@vueuse/core"; +import { readonly, ref, type Ref } from "vue"; + +export interface IWaitSingal{ + /** + * The waiting flag ref (readonly) + */ + readonly waiting: Readonly<Ref<boolean>>; + /** + * Sets the waiting flag to the value passed in + * @param waiting The value to set the waiting flag to + */ + setWaiting(waiting: boolean): void; +} + +const wait = (waiting: Ref<boolean>) : IWaitSingal => { + return{ + waiting: readonly(waiting), + setWaiting: (w: boolean) => set(waiting, w) + } +} + +export const useWait = (() => { + const waiting = ref(false) + /** + * Uses the internal waiting flag to determine if the component should be waiting + * based on pending apiCall() requests. + * @returns {Object} { waiting: Boolean, setWaiting: Function } + * @example //Waiting flag is reactive + * const { waiting, setWaiting } = useWait() + * setWaiting(true) //Manually set the waiting flag + * setWaiting(false) //Manually clear the waiting flag + */ + return (): IWaitSingal => wait(waiting); +})() + diff --git a/lib/vnlib.browser/src/index.ts b/lib/vnlib.browser/src/index.ts new file mode 100644 index 0000000..e2ba952 --- /dev/null +++ b/lib/vnlib.browser/src/index.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +/************************* + EXPORTS +*************************/ + +//Export the all util +export * from './util'; + +export type { WebMessage, ServerValidationError } from './types' + +//Mfa exports +export * from './mfa/login' +export * from './mfa/pki' +export * from './mfa/config' + +//Social exports +export * from './social' + +//Forward session public exports +export * from './session' + +//Axios exports +export { useAxios } from './axios' + +//User exports +export * from './user' + +//Export toast apis directly +export * from './toast' + +//Export helpers +export * from './helpers/apiCall' +export * from './helpers/autoHeartbeat' +export * from './helpers/autoScroll' +export * from './helpers/confirm' +export * from './helpers/envSize' +export * from './helpers/lastPage' +export * from './helpers/message' +export * from './helpers/pageGuard' +export * from './helpers/serverObjectBuffer' +export * from './helpers/validation' +export * from './helpers/wait' + +/************************* + SETUP/LOCALS +*************************/ + +import { cloneDeep } from 'lodash-es'; +import { setApiConfigInternal, type GlobalConfigUpdate } from './globalState'; +export type { GlobalApiConfig } from './globalState'; + +/** + * Configures the global api settings for the entire library, + * may be called at any time, but should be called in the main app component + * before other stateful components are mounted. + */ +export const configureApi = (config: GlobalConfigUpdate) => setApiConfigInternal(cloneDeep(config));
\ No newline at end of file diff --git a/lib/vnlib.browser/src/mfa/config.ts b/lib/vnlib.browser/src/mfa/config.ts new file mode 100644 index 0000000..d4bce35 --- /dev/null +++ b/lib/vnlib.browser/src/mfa/config.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { get } from "@vueuse/core"; +import { type MaybeRef } from "vue"; +import { useAxiosInternal } from "../axios" +import type { MfaMethod } from "./login" +import type { WebMessage } from '../types' + +export type UserArg = object; + +/** + * Represents the server api for interacting with the user's + * mfa configuration + */ +export interface MfaApi{ + /** + * disables the given mfa method + * @param type The mfa method to disable + * @param password The user's password + */ + disableMethod(type : MfaMethod, password: string) : Promise<WebMessage>; + + /** + * Initializes or updates the given mfa method configuration + * @param type The mfa method to initialize or update + * @param password The user's password + * @param userConfig Optional extended configuration for the mfa method. Gets passed to the server + */ + initOrUpdateMethod<T>(type: MfaMethod, password: string, userConfig?: UserArg) : Promise<WebMessage<T>>; + + /** + * Refreshes the enabled mfa methods + */ + getMethods(): Promise<MfaMethod[]>; +} + +/** + * Gets the api for interacting with the the user's mfa configuration + * @param mfaEndpoint The server mfa endpoint relative to the base url + * @returns An object containing the mfa api + */ +export const useMfaConfig = (mfaEndpoint: MaybeRef<string>): MfaApi =>{ + + const axios = useAxiosInternal(null) + + const getMethods = async () => { + //Get the mfa methods + const { data } = await axios.value.get<MfaMethod[]>(get(mfaEndpoint)); + return data + } + + const disableMethod = async (type: MfaMethod, password: string) : Promise<WebMessage> => { + const { post } = get(axios); + //Disable the mfa using the post method + const { data } = await post<WebMessage>(get(mfaEndpoint), { type, password }); + return data; + } + + const initOrUpdateMethod = async <T>(type: MfaMethod, password: string, userConfig?: UserArg) : Promise<WebMessage<T>> => { + const { put } = get(axios); + //enable or update the mfa using the put method + const { data } = await put<WebMessage<T>>(get(mfaEndpoint), { type, password, ...userConfig }); + return data; + } + + return { + disableMethod, + initOrUpdateMethod, + getMethods + } +} + diff --git a/lib/vnlib.browser/src/mfa/login.ts b/lib/vnlib.browser/src/mfa/login.ts new file mode 100644 index 0000000..a2bf120 --- /dev/null +++ b/lib/vnlib.browser/src/mfa/login.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { decodeJwt, type JWTPayload } from "jose"; +import { forEach, isNil } from 'lodash-es'; +import { get } from "@vueuse/core"; +import { debugLog } from "../util" +import { useUser, type ExtendedLoginResponse } from "../user"; +import { AccountEndpoint, type IUserInternal } from "../user/internal"; +import { useAxiosInternal } from "../axios"; +import type { Ref } from "vue"; +import type { Axios } from "axios"; +import type { ITokenResponse } from "../session"; +import type { WebMessage } from "../types"; + +export enum MfaMethod { + TOTP = 'totp' +} + +export interface IMfaSubmission { + /** + * TOTP code submission + */ + readonly code?: number; +} + +export interface IMfaMessage extends JWTPayload { + /** + * The type of mfa upgrade + */ + readonly type: MfaMethod; + /** + * The time in seconds that the mfa upgrade is valid for + */ + readonly expires?: number; +} + +export interface IMfaFlowContinuiation extends IMfaMessage { + /** + * Sumits the mfa message to the server and attempts to complete + * a login process + * @param message The mfa submission to send to the server + * @returns A promise that resolves to a login result + */ + submit: <T>(message: IMfaSubmission) => Promise<WebMessage<T>>; +} + +/** + * Interface for handling mfa upgrade submissions to the server + */ +export interface MfaSumissionHandler { + /** + * Submits an mfa upgrade submission to the server + * @param submission The mfa upgrade submission to send to the server to complete an mfa login + */ + submit<T>(submission: IMfaSubmission): Promise<WebMessage<T>>; +} + +/** + * Interface for processing mfa messages from the server of a given + * mfa type + */ +export interface IMfaTypeProcessor { + readonly type: MfaMethod; + /** + * Processes an MFA message payload of the registered mfa type + * @param payload The mfa message from the server as a string + * @param onSubmit The submission handler to use to submit the mfa upgrade + * @returns A promise that resolves to a Login request + */ + processMfa: (payload: IMfaMessage, onSubmit : MfaSumissionHandler) => Promise<IMfaFlowContinuiation> +} + +export interface IMfaLoginManager { + /** + * Logs a user in with the given username and password, and returns a login result + * or a mfa flow continuation depending on the login flow + * @param userName The username of the user to login + * @param password The password of the user to login + */ + login(userName: string, password: string): Promise<WebMessage | IMfaFlowContinuiation>; +} + +const getMfaProcessor = (user: IUserInternal, axios:Ref<Axios>) =>{ + + //Store handlers by their mfa type + const handlerMap = new Map<string, IMfaTypeProcessor>(); + + //Creates a submission handler for an mfa upgrade + const createSubHandler = (upgrade : string, finalize: (res: ITokenResponse) => Promise<void>) :MfaSumissionHandler => { + + const submit = async<T>(submission: IMfaSubmission): Promise<WebMessage<T>> => { + const { post } = get(axios); + + //All mfa upgrades use the account login endpoint + const ep = user.getEndpoint(AccountEndpoint.Login); + + //Get the mfa type from the upgrade message + const { type } = decodeJwt(upgrade) as IMfaMessage; + + //MFA upgrades currently use the login endpoint with a query string. The type that is captured from the upgrade + const endpoint = `${ep}?mfa=${type}`; + + //Submit request + const response = await post<ITokenResponse>(endpoint, { + //Pass raw upgrade message back to server as its signed + upgrade, + //publish submission + ...submission, + //Local time as an ISO string of the current time + localtime: new Date().toISOString() + }) + + // If the server returned a token, complete the login + if (response.data.success && !isNil(response.data.token)) { + await finalize(response.data) + } + + return response.data as WebMessage<T>; + } + + return { submit } + } + + const processMfa = (mfaMessage: string, finalize: (res: ITokenResponse) => Promise<void>) : Promise<IMfaFlowContinuiation> => { + + //Mfa message is a jwt, decode it (unsecure decode) + const mfa = decodeJwt(mfaMessage) as IMfaMessage; + debugLog(mfa) + + //Select the mfa handler + const handler = handlerMap.get(mfa.type); + + //If no handler is found, throw an error + if(!handler){ + throw new Error('Server responded with an unsupported two factor auth type, login cannot continue.') + } + + //Init submission handler + const submitHandler = createSubHandler(mfaMessage, finalize); + + //Process the mfa message + return handler.processMfa(mfa, submitHandler); + } + + const registerHandler = (handler: IMfaTypeProcessor) => { + handlerMap.set(handler.type, handler); + } + + return { processMfa, registerHandler } +} + +/** + * Gets a pre-configured TOTP mfa flow processor + * @returns A pre-configured TOTP mfa flow processor + */ +export const totpMfaProcessor = (): IMfaTypeProcessor => { + + const processMfa = async (payload: IMfaMessage, onSubmit: MfaSumissionHandler): Promise<IMfaFlowContinuiation> => { + return { ... payload, submit: onSubmit.submit } + } + + return { + type: MfaMethod.TOTP, + processMfa + } +} + +/** + * Gets the mfa login handler for the accounts backend + * @param handlers A list of mfa handlers to register + * @returns The configured mfa login handler + */ +export const useMfaLogin = (handlers : IMfaTypeProcessor[]): IMfaLoginManager => { + + //get the user instance + const user = useUser() as IUserInternal + + const axios = useAxiosInternal(null) + + //Get new mfa processor + const mfaProcessor = getMfaProcessor(user, axios); + + //Login that passes through logins with mfa + const login = async <T>(userName: string, password: string) : Promise<ExtendedLoginResponse<T> | IMfaFlowContinuiation> => { + + //User-login with mfa response + const response = await user.login(userName, password); + + const { mfa } = response as { mfa?: boolean } + + //Get the mfa upgrade message from the server + if (mfa === true){ + + // Process the two factor auth message and add it to the response + const result = await mfaProcessor.processMfa(response.result as string, response.finalize); + + return { + ...result + }; + } + + //If no mfa upgrade message is returned, the login is complete + return response as ExtendedLoginResponse<T>; + } + + //Register all the handlers + forEach(handlers, mfaProcessor.registerHandler); + + return { login } +} + diff --git a/lib/vnlib.browser/src/mfa/pki.ts b/lib/vnlib.browser/src/mfa/pki.ts new file mode 100644 index 0000000..f2f08c6 --- /dev/null +++ b/lib/vnlib.browser/src/mfa/pki.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { decodeJwt } from "jose" +import { get } from "@vueuse/core" +import { type MaybeRef } from "vue"; +import { useAxiosInternal } from "../axios" +import { useUser } from "../user" +import { debugLog } from "../util" +import type { WebMessage } from '../types' +import type { IUserLoginRequest } from "../user/types" +import type { ITokenResponse } from "../session" +import type { UserArg } from "./config"; + + +/** + * Represents the server api for loging in with a signed OTP + */ +export interface PkiLogin{ + /** + * Authenticates a user with a signed JWT one time password + * @param pkiJwt The user input JWT signed one time password for authentication + * @returns A promise that resolves to the login result + */ + login<T>(pkiJwt: string): Promise<WebMessage<T>> +} + +export interface PkiPublicKey { + readonly kid: string; + readonly alg: string; + readonly kty: string; + readonly crv: string; + readonly x: string; + readonly y: string; +} + +/** + * A base, non-mfa integrated PKI endpoint adapter interface + */ +export interface PkiApi { + /** + * Initializes or updates the pki method for the current user + * @param publicKey The user's public key to initialize or update the pki method + * @param options Optional extended configuration for the pki method. Gets passed to the server + */ + addOrUpdate(publicKey: PkiPublicKey, options?: UserArg): Promise<WebMessage>; + /** + * Disables the pki method for the current user and passes the given options to the server + */ + disable(options?: UserArg): Promise<WebMessage>; + /** + * Gets all public keys for the current user + */ + getAllKeys(): Promise<PkiPublicKey[]>; + /** + * Removes a single public key by it's id for the current user + */ + removeKey(kid: string): Promise<WebMessage>; +} + +interface PkiLoginRequest extends IUserLoginRequest{ + login: string; +} + +/** + * Creates a pki login api that allows for authentication with a signed JWT + */ +export const usePkiAuth = (pkiEndpoint: MaybeRef<string>): PkiLogin =>{ + + const axios = useAxiosInternal() + const { prepareLogin } = useUser() + + const login = async <T>(pkiJwt: string): Promise<WebMessage<T>> => { + + //try to decode the jwt to confirm its form is valid + const jwt = decodeJwt(pkiJwt) + debugLog(jwt) + + //Prepare a login message + const loginMessage = await prepareLogin() as PkiLoginRequest; + + //Set the 'login' field to the otp + loginMessage.login = pkiJwt; + + const { post } = get(axios) + const { data } = await post<ITokenResponse>(get(pkiEndpoint), loginMessage) + + data.getResultOrThrow(); + + //Finalize the login + await loginMessage.finalize(data); + + return data as WebMessage<T>; + } + + return { login } +} + +/** + * Gets the api for interacting with the the user's pki configuration + * @param pkiEndpoint The server pki endpoint relative to the base url + * @returns An object containing the pki api + */ +export const usePkiConfig = (pkiEndpoint: MaybeRef<string>): PkiApi => { + + const axios = useAxiosInternal(null) + + const addOrUpdate = async (publicKey: PkiPublicKey, options?: UserArg): Promise<WebMessage> => { + const { patch } = get(axios); + const { data } = await patch<WebMessage>(get(pkiEndpoint), { ...publicKey, ...options }); + return data; + } + + const getAllKeys = async (): Promise<PkiPublicKey[]> => { + const { data } = await axios.value.get<WebMessage<PkiPublicKey[]>>(get(pkiEndpoint)); + return data.getResultOrThrow(); + } + + const disable = async (options?: UserArg): Promise<WebMessage> => { + const { delete: del } = get(axios); + //emtpy delete request deletes all keys + const { data } = await del<WebMessage>(get(pkiEndpoint), options); + return data; + } + + const removeKey = async (kid: string): Promise<WebMessage> => { + const { delete: del } = get(axios); + //Delete request with the id parameter deletes a single key + const { data } = await del<WebMessage>(`${get(pkiEndpoint)}?id=${kid}`); + return data; + } + + return { addOrUpdate, disable, getAllKeys, removeKey } +} diff --git a/lib/vnlib.browser/src/session/cookies.ts b/lib/vnlib.browser/src/session/cookies.ts new file mode 100644 index 0000000..0e3e613 --- /dev/null +++ b/lib/vnlib.browser/src/session/cookies.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { defer, isEqual } from 'lodash-es' +import { computed, ref, type Ref } from 'vue' +import Cookies from 'universal-cookie' + +export interface CookieMonitor<T> { + readonly cookieValue: Readonly<Ref<T>>; + readonly enabled: Readonly<Ref<boolean>>; +} + +export type CookieValueConveter<T> = (value: string) => T; + +/** + * Creates a reactive wrapper that monitors a single cookie value for changes + * @param enabled A ref that indicates if the cookie should be monitored + * @param cookieName The name of the cookie to monitor + * @param converter An optional converter function to convert the cookie value to a different type + */ +export const createCookieMonitor = <T = string>(enabled: Ref<boolean>, cookieName: Ref<string | undefined>, converter?: CookieValueConveter<T>) : CookieMonitor<T> => { + + //Set default converter + converter ??= (value: string) => value as unknown as T; + + const cookieJar = new Cookies(); + const cookieRef = ref<string>(''); + const cookieValue = computed<T>(() => converter!(cookieRef.value)); + + //Store last value to avoid unnecessary updates + let lastValue = ''; + + const get = (): string => cookieJar.get<string>(cookieName.value || '', { doNotParse: true }); + + const checkAndUpdateCookie = () => { + if (!enabled.value) { + return; + } + //Get the new cookeie value + const newValue = get() + if (!isEqual(newValue, lastValue)) { + lastValue = cookieRef.value = newValue + } + } + + //Watch for changes to the cookie and update the ref only if its enabled + cookieJar.addChangeListener(() => defer(checkAndUpdateCookie)) + + //Initial cookie check + checkAndUpdateCookie() + + return { cookieValue , enabled } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/session/index.ts b/lib/vnlib.browser/src/session/index.ts new file mode 100644 index 0000000..d3fdc2b --- /dev/null +++ b/lib/vnlib.browser/src/session/index.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { computed } from 'vue'; +import { toSafeInteger } from 'lodash-es'; +import { toRefs } from "@vueuse/core"; +import { createCookieMonitor } from "./cookies"; +import { StorageKey, createStorageSlot, getGlobalStateInternal } from "../globalState"; +import { createSession, type IKeyStorage, type IStateStorage } from "./internal"; +import type { ISession } from "./types"; + +//Export all internal types +export type * from './types' + +/** + * Gets the global session api instance + * @returns The session api instance + */ +export const useSession = (() => { + + //Use reactive config + const { session } = getGlobalStateInternal() + const { cookiesEnabled } = toRefs(session) + + const liCookieName = computed<string | undefined>(() => session.value.loginCookieName) + + const loginCookie = createCookieMonitor(cookiesEnabled, liCookieName, v => toSafeInteger(v)); + + //create reactive storage slots + const keyStore = createStorageSlot<IKeyStorage>(StorageKey.Keys, { priv: null, pub: null }) + const sessionState = createStorageSlot<IStateStorage>(StorageKey.Session, { token: null, browserId: null }) + + return (): ISession => createSession(session, loginCookie, keyStore, sessionState); +})(); diff --git a/lib/vnlib.browser/src/session/internal.ts b/lib/vnlib.browser/src/session/internal.ts new file mode 100644 index 0000000..d7856c3 --- /dev/null +++ b/lib/vnlib.browser/src/session/internal.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { defaults, isEmpty, isNil } from 'lodash-es'; +import { computed, watch, type Ref } from "vue"; +import { get, set, toRefs } from '@vueuse/core'; +import { SignJWT } from 'jose' +import crypto, { decryptAsync, getRandomHex } from "../webcrypto"; +import { ArrayBuffToBase64, Base64ToUint8Array } from '../binhelpers' +import { debugLog } from "../util"; +import type { CookieMonitor } from './cookies' +import type { ISession, ISessionKeyStore, ITokenResponse, ClientCredential, SessionConfig } from './types' + +export interface IStateStorage { + token: string | null; + browserId: string | null; +} +export interface IKeyStorage { + priv: string | null; + pub: string | null; +} + +interface IInternalKeyStore extends ISessionKeyStore { + getPublicKey(): Promise<string>; + clearKeys(): void; +} + + +enum ServerLiTokenValues{ + NoToken = 0, + LocalAccount = 1, + ExternalAccount = 2 +} + +const createKeyStore = (storage: Ref<IKeyStorage>, keyAlg: Ref<AlgorithmIdentifier>): IInternalKeyStore => { + + const { priv, pub } = toRefs(storage) + + const getPublicKey = async (): Promise<string> => { + //Check if we have a public key + if (isNil(pub.value)) { + //If not, generate a new key pair + await checkAndSetKeysAsync(); + return pub.value!; + } + return pub.value; + } + + const setCredentialAsync = async (keypair: CryptoKeyPair): Promise<void> => { + // Store the private key + const newPrivRaw = await crypto.exportKey('pkcs8', keypair.privateKey); + const newPubRaw = await crypto.exportKey('spki', keypair.publicKey); + + //Store keys as base64 strings + priv.value = ArrayBuffToBase64(newPrivRaw); + pub.value = ArrayBuffToBase64(newPubRaw); + } + + const clearKeys = async (): Promise<void> => { + set(priv, null); + set(pub, null); + } + + const checkAndSetKeysAsync = async (): Promise<void> => { + // Check if we have a key pair already + if (!isNil(priv.value) && !isNil(pub.value)) { + return; + } + + // If not, generate a new key pair + const keypair = await crypto.generateKey(keyAlg.value, true, ['encrypt', 'decrypt']) as CryptoKeyPair; + + //Set credential + await setCredentialAsync(keypair); + + debugLog("Generated new client keypair, none were found") + } + + const regenerateKeysAsync = (): Promise<void> => { + //Clear keys and generate new ones + clearKeys(); + return checkAndSetKeysAsync(); + } + + const decryptDataAsync = async (data: string | ArrayBuffer): Promise<ArrayBuffer> => { + // Convert the private key to a Uint8Array from its base64 string + const keyData = Base64ToUint8Array(priv.value || "") + + //import private key as pkcs8 + const privKey = await crypto.importKey('pkcs8', keyData, keyAlg.value, false, ['decrypt']) + + // Decrypt the data and return it + return await decryptAsync(keyAlg.value, privKey, data, false) as ArrayBuffer + } + + const decryptAndHashAsync = async (data: string | ArrayBuffer): Promise<string> => { + // Decrypt the data + const decrypted = await decryptDataAsync(data) + + // Hash the decrypted data + const hashed = await crypto.digest({ name: 'SHA-256' }, decrypted) + + // Convert the hash to a base64 string + return ArrayBuffToBase64(hashed) + } + + return { + getPublicKey, + clearKeys, + regenerateKeysAsync, + decryptDataAsync, + decryptAndHashAsync + } +} + +const createUtil = (utilState: Ref<SessionConfig>, sessionStorage: Ref<IStateStorage>, keyStorage: Ref<IKeyStorage>) => { + + const otpNonceSize = 16; + const { browserIdSize, signatureAlgorithm: sigAlg, keyAlgorithm: keyAlg } = toRefs(utilState); + + const KeyStore = createKeyStore(keyStorage, keyAlg); + + //Create session state and key store + const { browserId, token } = toRefs(sessionStorage); + + const getBrowserId = (): string => { + // Check browser id + if (isNil(browserId.value)) { + // generate a new random value and store it + browserId.value = getRandomHex(browserIdSize.value); + debugLog("Generated new browser id, none was found") + } + + return browserId.value; + } + + const updateCredentials = async (response: ITokenResponse): Promise<void> => { + /* + * The server sends an encrypted HMAC key + * using our public key. We need to decrypt it + * and use it to sign messages to the server. + */ + const decrypted = await KeyStore.decryptDataAsync(response.token) + + // Convert the hash to a base64 string and store it + token.value = ArrayBuffToBase64(decrypted) + } + + const generateOneTimeToken = async (): Promise<string | null> => { + //we need to get the shared key from storage and decode it, it may be null if not set + const sharedKey = token.value ? Base64ToUint8Array(token.value) : null + + if (!sharedKey) { + return null; + } + + //Inint jwt with a random nonce + const nonce = getRandomHex(otpNonceSize); + + //Get the alg from the config + const alg = get(sigAlg); + + const jwt = new SignJWT({ 'nonce': nonce }) + //Set alg + jwt.setProtectedHeader({ alg }) + //Iat is the only required claim at the current time utc + .setIssuedAt() + .setAudience(window.location.origin) + + //Sign the jwt + const signedJWT = await jwt.sign(sharedKey) + + return signedJWT; + } + + const clearLoginState = (): void => { + set(browserId, null); + set(token, null); + KeyStore.clearKeys(); + } + + const getClientSecInfo = async (): Promise<ClientCredential> => { + //Generate and get the credential info + const publicKey = await KeyStore.getPublicKey(); + const browserId = getBrowserId(); + return { publicKey, browserId }; + } + + return { + KeyStore, + getClientSecInfo, + updateCredentials, + generateOneTimeToken, + clearLoginState + }; +} + +export const createSession = ( + sessionConfig: Readonly<Ref<SessionConfig>>, + cookies: CookieMonitor<number>, + keys: Ref<IKeyStorage>, + state: Ref<IStateStorage> +): ISession =>{ + + //assign defaults to storage slots before toRefs call + defaults(state.value, { token: null, browserId: null }); + defaults(keys.value, { priv: null, pub: null }); + + //Create the session util + const util = createUtil(sessionConfig, state, keys); + const { token } = toRefs(state); + const isServerTokenSet = computed<boolean>(() => !isEmpty(token.value)); + + //Translate the cookie value to a LoginCookieValue + const { enabled, cookieValue } = cookies + + //If cookies are disabled, only allow the user to be logged in if the token is set + const loggedIn = computed<boolean>(() => enabled.value ? cookieValue.value > 0 && isServerTokenSet.value : isServerTokenSet.value); + + const isLocalAccount = computed<boolean>(() => cookieValue.value === ServerLiTokenValues.LocalAccount); + + //Watch the logged in value and if it changes from true to false, clear the token + watch(loggedIn, value => value ? null : token.value = null); + + return { + loggedIn, + isLocalAccount, + ...util + } +} + diff --git a/lib/vnlib.browser/src/session/types.ts b/lib/vnlib.browser/src/session/types.ts new file mode 100644 index 0000000..bbb5de6 --- /dev/null +++ b/lib/vnlib.browser/src/session/types.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import type { Ref } from "vue"; +import type { WebMessage } from "../types"; + +export interface SessionConfig { + readonly browserIdSize: number; + readonly signatureAlgorithm: string; + readonly keyAlgorithm: AlgorithmIdentifier; +} + + +export interface ISessionKeyStore { + /** + * Regenerates the credentials and stores them in the key store + */ + regenerateKeysAsync(): Promise<void>; + + /** + * Decrypts the server encrypted that conforms to the vnlib protocol + * @param data The data to encrypt, may be a string or an array buffer + */ + decryptDataAsync(data: string | ArrayBuffer): Promise<ArrayBuffer>; + + /** + * Decrypts and hashes the data that conforms to the vnlib protocol + * @param data The data to decrypt and hash, may be a string or an array buffer + */ + decryptAndHashAsync(data: string | ArrayBuffer): Promise<string>; +} + +/** + * Represents the current server/client session state + */ +export interface ISession { + /** + * A readonly reactive reference to the login status + * of the session. + */ + readonly loggedIn: Readonly<Ref<boolean>>; + + /** + * A readonly reactive reference indicating if the client + * is using a local account or a remote/oauth account + */ + readonly isLocalAccount: Readonly<Ref<boolean>>; + + /** + * The internal session key store + */ + readonly KeyStore: ISessionKeyStore; + + /** + * Updates session credentials from the server response + * @param response The raw response from the server + */ + updateCredentials(response: ITokenResponse): Promise<void>; + + /** + * Computes a one time key for a fetch request security header + * It is a signed jwt token that is valid for a short period of time + */ + generateOneTimeToken(): Promise<string | null>; + + /** + * Clears the session login status and removes all client side + * session data + */ + clearLoginState(): void; + + /** + * Gets the client's security info + */ + getClientSecInfo(): Promise<ClientCredential>; +} + +export interface ITokenResponse<T = unknown> extends WebMessage<T> { + readonly token: string; +} + +/** + * Represents the browser's client credential + */ +export interface ClientCredential{ + /** + * The browser id of the current client + */ + readonly browserId: string; + /** + * The public key of the current client + */ + readonly publicKey: string; +}
\ No newline at end of file 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<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; +} + +/** + * 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, { 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<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'); + } + + //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); + + //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<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), + } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/toast/index.ts b/lib/vnlib.browser/src/toast/index.ts new file mode 100644 index 0000000..054c87e --- /dev/null +++ b/lib/vnlib.browser/src/toast/index.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { shallowRef, type MaybeRef } from 'vue' +import { set } from '@vueuse/core' +import { debugLog } from '../util' +import { createFormToaster, createGeneralToaster } from './toaster' +import type { INotifier, CombinedToaster, ToasterNotifier } from './types' + +export type * from './types' + +class DefaultNotifier implements INotifier { + notify(config: { title:string, text:string }): void { + debugLog(`Notification: ${config.title} - ${config.text}`) + } + close(id: string): void { + debugLog(`Notification closed: ${id}`) + } +} + +//Combined toaster impl +const createCombinedToaster = (notifier: MaybeRef<INotifier>) : CombinedToaster => { + const form = createFormToaster(notifier); + const general = createGeneralToaster(notifier); + + const close = (id?: string) => { + form.close(id); + general.close(id); + } + + return Object.freeze({ form, general, close }); +} + +// The program handler for the notification +const _global = shallowRef<INotifier>(new DefaultNotifier()) + +/** + * Configures the notification handler. + * @param {*} notifier The method to call when a notification is to be displayed + * @returns The notifier + */ +export const configureNotifier = (notifier : INotifier) => set(_global, notifier) + +/** + * Gets the default toaster for general notifications + * and the form toaster for form notifications + * @returns {Object} The toaster contianer object + */ +export const useToaster = (notifier?: MaybeRef<INotifier>): CombinedToaster => createCombinedToaster(notifier ?? _global); + +/** + * Gets the default toaster for from notifications + */ +export const useFormToaster = (notifier?: MaybeRef<INotifier>): ToasterNotifier => createFormToaster(notifier ?? _global); + +/** + * Gets the default toaster for general notifications + * @returns {Object} The toaster contianer object + */ +export const useGeneralToaster = (notifier?: MaybeRef<INotifier>): ToasterNotifier => createGeneralToaster(notifier ?? _global);
\ No newline at end of file diff --git a/lib/vnlib.browser/src/toast/toaster.ts b/lib/vnlib.browser/src/toast/toaster.ts new file mode 100644 index 0000000..0f178f8 --- /dev/null +++ b/lib/vnlib.browser/src/toast/toaster.ts @@ -0,0 +1,83 @@ + +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { assign } from 'lodash-es' +import { get } from '@vueuse/core' +import type { MaybeRef } from 'vue' +import type { INotifier, ToasterNotifier } from './types' + + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const createToaster = (notifier: MaybeRef<INotifier>, fallback: object): ToasterNotifier => { + + const success = (config: any): void => { + config.type = 'success' + assign(config, fallback) + get(notifier).notify(config) + } + + const error = (config: any): void => { + config.type = 'error' + assign(config, fallback) + get(notifier).notify(config) + } + + const info = (config: any): void => { + config.type = 'info' + assign(config, fallback) + get(notifier).notify(config) + } + + const close = (id: string): void => get(notifier).close(id); + + const notifyError = (title: string, message?: string): void => error({ title, text: message }); + + return { success, error, info, close, notifyError } +} + +/** + * Creates a toaster for form notifications + * @param notifier The send to write the notifications to + * @returns A toaster to send form notifications to + */ +export const createFormToaster = (notifier: MaybeRef<INotifier>) => { + + const formConfig = Object.freeze({ + group: 'form', + ignoreDuplicates: true, + // Froms only allow for one notification at a time + max: 1, + // Disable close on click + closeOnClick: false + }); + + return createToaster(notifier, formConfig); +} + +/** + * The general toaster implementation of the IToaster interface + */ +export const createGeneralToaster = (notifier: MaybeRef<INotifier>) => { + //Setup config for the general toaster w/ the group set to general + const config = Object.freeze({ + group: 'general' + }); + return createToaster(notifier, config); +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/toast/types.ts b/lib/vnlib.browser/src/toast/types.ts new file mode 100644 index 0000000..be74301 --- /dev/null +++ b/lib/vnlib.browser/src/toast/types.ts @@ -0,0 +1,48 @@ + +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +export interface ToastEvent{ + +} + +export interface IToaster{ + success(config : object) : void; + error(config : object) : void; + info(config : object) : void; + close(id? : string) : void; +} + +export interface INotifier{ + notify(config : object) : void; + close(id : string) : void; +} + +export interface IErrorNotifier { + notifyError(title: string, message?: string): void; + close(id? : string): void; +} + +export interface ToasterNotifier extends IToaster, IErrorNotifier{ +} + +export interface CombinedToaster { + readonly form: IToaster; + readonly general: IToaster; +} diff --git a/lib/vnlib.browser/src/types.ts b/lib/vnlib.browser/src/types.ts new file mode 100644 index 0000000..9e2b670 --- /dev/null +++ b/lib/vnlib.browser/src/types.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +/** + * Represents a uniform message from the server + */ +export interface WebMessage<T = unknown> { + /** + * The result of the operation, or an error message + * if the sucess value is false + */ + readonly result : T | string; + /** + * True if the operation was successful, false otherwise + */ + readonly success : boolean; + /** + * Validation errors, if any occured + */ + readonly errors?: ServerValidationError[] + + /** + * Returns the result, or throws an error if the operation was not successful + */ + getResultOrThrow() : T; +} + +/** + * Represents a validation error from the server for a specific property + */ +export interface ServerValidationError{ + /** + * The property that failed validation + */ + readonly property : string; + /** + * The error message + */ + readonly message: string; +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/user/index.ts b/lib/vnlib.browser/src/user/index.ts new file mode 100644 index 0000000..8b037ba --- /dev/null +++ b/lib/vnlib.browser/src/user/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { useSession } from "../session"; +import { useAxiosInternal } from '../axios'; +import { createUser } from './internal' +import { StorageKey, getGlobalStateInternal, createStorageSlot } from '../globalState'; +import type { UserState } from './types'; + +//Export public types +export type * from './types' + +/** + * Gets the global user interface + * @returns The users api instance + */ +export const useUser = (() => { + const _userStorage = createStorageSlot<UserState>(StorageKey.User, { userName: undefined }); + const { user } = getGlobalStateInternal(); + //Use global axios instance + const _axios = useAxiosInternal(); + //Use global session + const _session = useSession(); + + return () => createUser(user, _axios, _session, _userStorage); +})() diff --git a/lib/vnlib.browser/src/user/internal.ts b/lib/vnlib.browser/src/user/internal.ts new file mode 100644 index 0000000..4a5aed6 --- /dev/null +++ b/lib/vnlib.browser/src/user/internal.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { isNil, defaultTo, defaults } from 'lodash-es' +import { computed, watch, type Ref } from "vue" +import { get, set, toRefs } from '@vueuse/core' +import type { Axios, AxiosResponse } from "axios" +import type { ISession, ITokenResponse } from '../session' +import type { WebMessage } from '../types' +import type { User, UserConfig, UserProfile, ExtendedLoginResponse, UserState } from './types' + +export enum AccountEndpoint { + Login = "login", + Logout = "logout", + Register = "register", + Reset = "reset", + Profile = "profile", + HeartBeat = "keepalive" +} + +export interface IUserInternal extends User { + getEndpoint: (endpoint: AccountEndpoint) => string; +} + +export const createUser = ( + config: Readonly<Ref<UserConfig>>, + axios:Readonly<Ref<Axios>>, + session: ISession, + state: Ref<UserState> +): IUserInternal => { + + + //always set default value before call to toRefs + defaults(state.value, { userName: undefined }) + + const { accountBasePath } = toRefs(config); + const { userName } = toRefs(state); + + const getEndpoint = (endpoint: AccountEndpoint) => `${get(accountBasePath)}/${endpoint}`; + + const prepareLogin = async () => { + //Store a copy of the session data and the current time for the login request + const finalize = async (response: ITokenResponse): Promise<void> => { + //Update the session with the new credentials + await session.updateCredentials(response); + + //Update the user state with the new username + set(userName, (response as { email? : string }).email); + } + + //Get or regen the client public key + const { publicKey, browserId } = await session.getClientSecInfo(); + + return { + clientid: browserId, + pubkey: publicKey, + localtime: new Date().toISOString(), + locallanguage: navigator.language, + username: '', + password: '', + finalize + } + } + + //Expose the logged in state behind multiple refs + const loggedIn = computed(() => session.loggedIn.value); + + //We want to watch the loggin ref and if it changes to false, clear the username + watch(loggedIn, value => value === false ? set(userName, undefined) : undefined) + + const logout = async (): Promise<WebMessage> => { + //Get axios with logout endpoint + const { post } = get(axios); + const ep = getEndpoint(AccountEndpoint.Logout); + + // Send a post to the account logout endpoint to logout + const { data } = await post<WebMessage>(ep, {}); + + //regen session credentials on successful logout + await session.KeyStore.regenerateKeysAsync() + + // return the response + return data + } + + const login = async <T>(userName: string, password: string): Promise<ExtendedLoginResponse<T>> => { + //Get axios and the login endpoint + const { post } = get(axios); + const ep = getEndpoint(AccountEndpoint.Login); + + const prepped = await prepareLogin(); + + //Set the username and password + prepped.username = userName; + prepped.password = password; + + //Send the login request + const { data } = await post<ITokenResponse<T>>(ep, prepped); + + // Check the response + if(data.success === true) { + + // If the server returned a token, complete the login + if (!isNil(data.token)) { + await prepped.finalize(data) + } + } + + return { + ...data, + finalize: prepped.finalize + } + } + + const getProfile = async <T extends UserProfile>(): Promise <T> => { + //Get axios and the profile endpoint + const ax = get(axios) + const ep = getEndpoint(AccountEndpoint.Profile); + + // Get the user's profile from the profile endpoint + const response = await ax.get<T>(ep); + + //Update the internal username if it was set by the server + const newUsername = defaultTo(response.data.email, get(userName)); + + //Update the user state with the new username from the server + set(userName, newUsername); + + // return response data + return response.data + } + + const resetPassword = async (current: string, newPass: string, args: object): Promise<WebMessage> => { + //Get axios and the reset password endpoint + const { post } = get(axios); + const ep = getEndpoint(AccountEndpoint.Reset); + + // Send a post to the reset password endpoint + const { data } = await post<WebMessage>(ep, { + current, + new_password: newPass, + ...args + }) + + return data + } + + const heartbeat = async (): Promise <AxiosResponse> => { + //Get axios and the heartbeat endpoint + const { post } = get(axios); + const ep = getEndpoint(AccountEndpoint.HeartBeat); + + // Send a post to the heartbeat endpoint + const response = await post<ITokenResponse>(ep); + + //If success flag is set, update the credentials + if(response.data.success){ + //Update credential + await session.updateCredentials(response.data); + } + return response; + } + + return{ + userName, + prepareLogin, + logout, + login, + getProfile, + resetPassword, + heartbeat, + getEndpoint, + } +} diff --git a/lib/vnlib.browser/src/user/types.ts b/lib/vnlib.browser/src/user/types.ts new file mode 100644 index 0000000..37d2829 --- /dev/null +++ b/lib/vnlib.browser/src/user/types.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import type { AxiosResponse } from "axios" +import type { Ref } from "vue" +import type { WebMessage } from "../types" +import type { ITokenResponse } from "../session" + +/** + * Represents the user/account server api + */ +export interface User { + /** + * A reactive ref to the username of the current user. + * Its updated on calls to getProfile + */ + readonly userName: Ref<string | undefined> + /** + * Prepares a login request for the server + */ + prepareLogin(): Promise<IUserLoginRequest> + /** + * Attempts to log the user out + */ + logout(): Promise<WebMessage> + /** + * Attempts to log the user in with a possible mfa upgrade result + * @param userName The username to login with + * @param password The password to login with + * @returns A promise that resolves to the login result + */ + login<T>(userName: string, password: string): Promise<ExtendedLoginResponse<T>> + /** + * Gets the user profile from the server + */ + getProfile<T extends UserProfile>(): Promise<T> + /** + * Resets the password for the current user + * @param current the user's current password + * @param newPass the user's new password + * @param args any additional arguments to send to the server + */ + resetPassword(current: string, newPass: string, args: object): Promise<WebMessage> + /** + * Sends a heartbeat to the server to keep the session alive + * and regenerate credentials as designated by the server. + */ + heartbeat(): Promise<AxiosResponse> +} + +export type IUser = User; + +export interface IUserLoginRequest { + /** + * Finalizes a login process with the given response from the server + * @param response The finalized login response from the server + */ + finalize(response: ITokenResponse): Promise<void> +} + +export interface UserConfig { + readonly accountBasePath: string; +} + +export interface UserState{ + /** + * A reactive ref to the username of the current user. + * Its updated on calls to getProfile + */ + readonly userName: string | undefined +} + +export interface ExtendedLoginResponse<T> extends WebMessage<T> { + finalize: (response : ITokenResponse) => Promise<void> +} + +export interface UserProfile { + readonly email: string | undefined; +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/util.ts b/lib/vnlib.browser/src/util.ts new file mode 100644 index 0000000..c5df99f --- /dev/null +++ b/lib/vnlib.browser/src/util.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +/** + * Writes logs to the console if the environment is set to development + */ +export const debugLog = function (...args : unknown[]) { + if (process.env.NODE_ENV === 'development') { + console.log(...args) + } +}
\ No newline at end of file diff --git a/lib/vnlib.browser/src/webcrypto.ts b/lib/vnlib.browser/src/webcrypto.ts new file mode 100644 index 0000000..d2c7640 --- /dev/null +++ b/lib/vnlib.browser/src/webcrypto.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { isArrayBuffer, isPlainObject, isString } from 'lodash-es'; +import { ArrayBuffToBase64, Base64ToUint8Array, ArrayToHexString } from './binhelpers'; + +const crypto = window?.crypto?.subtle || {}; + +/** + * Signs the dataBuffer using the specified key and hmac algorithm by its name eg. 'SHA-256' + * @param {ArrayBuffer | String} dataBuffer The data to sign, either as an ArrayBuffer or a base64 string + * @param {ArrayBuffer | String} keyBuffer The raw key buffer, or a base64 encoded string + * @param {String} alg The name of the hmac algorithm to use eg. 'SHA-256' + * @param {String} [toBase64 = false] The output format, the array buffer data, or true for base64 string + * @returns {Promise<ArrayBuffer | String>} The signature as an ArrayBuffer or a base64 string + */ +export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer: ArrayBuffer | string, alg : string, toBase64 = false) +: Promise<ArrayBuffer | string> => { + // Check key argument type + const rawKeyBuffer = isString(keyBuffer) ? Base64ToUint8Array(keyBuffer as string) : keyBuffer as ArrayBuffer; + + // Check data argument type + const rawDataBuffer = isString(dataBuffer) ? Base64ToUint8Array(dataBuffer as string) : dataBuffer as ArrayBuffer; + + // Get the key + const hmacKey = await crypto.importKey('raw', rawKeyBuffer, { name: 'HMAC', hash: alg }, false, ['sign']); + + // Sign hmac data + const digest = await crypto.sign('HMAC', hmacKey, rawDataBuffer); + + // Encode to base64 if needed + return toBase64 ? ArrayBuffToBase64(digest) : digest; +} +/** + * @function decryptAsync Decrypts syncrhonous or asyncrhonsous en encypted data + * asynchronously. + * @param {any} data The encrypted data to decrypt. (base64 string or ArrayBuffer) + * @param {any} privKey The key to use for decryption (base64 String or ArrayBuffer). + * @param {Object} algorithm The algorithm object to use for decryption. + * @param {Boolean} toBase64 If true, the decrypted data will be returned as a base64 string. + * @returns {Promise} The decrypted data. + */ +export const decryptAsync = async ( + algorithm: AlgorithmIdentifier, + privKey: BufferSource | CryptoKey | JsonWebKey, + data: string | ArrayBuffer, + toBase64 = false): Promise<string | ArrayBuffer> => +{ + // Check data argument type and decode if needed + const dataBuffer = isString(data) ? Base64ToUint8Array(data as string) : data as ArrayBuffer; + + let privateKey = privKey + // Check key argument type + if (privKey instanceof CryptoKey) { + privateKey = privKey + } + // If key is binary data, then import it as raw data + else if (isArrayBuffer(privKey)) { + privateKey = await crypto.importKey('raw', privKey, algorithm, true, ['decrypt']) + } + // If the key is an object, then import it as a jwk + else if (isPlainObject(privKey)) { + privateKey = await crypto.importKey('jwk', privKey as JsonWebKey, algorithm, true, ['decrypt']) + } + + // Decrypt the data and return it + const decrypted = await crypto.decrypt(algorithm, privateKey as CryptoKey, dataBuffer) + return toBase64 ? ArrayBuffToBase64(decrypted) : decrypted +} + +export const getRandomHex = (size: number) : string => { + // generate a new random secret and store it + const randBuffer = new Uint8Array(size) + // generate random id directly on the window.crypto object + window.crypto.getRandomValues(randBuffer) + // Store the id in the session as hex + return ArrayToHexString(randBuffer) +} + +//default export subtle crypto +export default crypto;
\ No newline at end of file |