aboutsummaryrefslogtreecommitdiff
path: root/extension/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'extension/src/features')
-rw-r--r--extension/src/features/auth-api.ts106
-rw-r--r--extension/src/features/framework/index.ts6
-rw-r--r--extension/src/features/identity-api.ts46
-rw-r--r--extension/src/features/index.ts7
-rw-r--r--extension/src/features/mfa-api.ts87
-rw-r--r--extension/src/features/pki-api.ts (renamed from extension/src/features/account-api.ts)54
-rw-r--r--extension/src/features/types.ts9
7 files changed, 257 insertions, 58 deletions
diff --git a/extension/src/features/auth-api.ts b/extension/src/features/auth-api.ts
index f47d505..fbc9420 100644
--- a/extension/src/features/auth-api.ts
+++ b/extension/src/features/auth-api.ts
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 Vaughn Nugent
+// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
@@ -14,15 +14,17 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { AxiosInstance } from "axios";
-import { get } from "@vueuse/core";
-import { computed } from "vue";
-import { delay } from "lodash";
-import { usePkiAuth, useSession, useUser } from "@vnuge/vnlib.browser";
+import { get, useTimeoutFn, set } from "@vueuse/core";
+import { computed, shallowRef } from "vue";
+import { clone, defer, delay } from "lodash";
+import { IMfaFlowContinuiation, totpMfaProcessor, useMfaLogin, usePkiAuth, useSession, useUser,
+ type IMfaSubmission, type IMfaMessage, type WebMessage
+} from "@vnuge/vnlib.browser";
+import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework";
+import { waitForChangeFn } from "./util";
import type { ClientStatus } from "./types";
import type { AppSettings } from "./settings";
import type { JsonObject } from "type-fest";
-import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework";
-import { waitForChangeFn } from "./util";
export interface ProectedHandler<T extends JsonObject> {
@@ -38,11 +40,12 @@ export interface ApiMessageHandler<T extends JsonObject> {
}
export interface UserApi extends FeatureApi {
- login: (token: string) => Promise<boolean>
+ login(username: string, password?: string): Promise<boolean>
logout: () => Promise<void>
getProfile: () => Promise<any>
getStatus: () => Promise<ClientStatus>
waitForChange: () => Promise<void>
+ submitMfa: (submission: IMfaSubmission) => Promise<boolean>
}
export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
@@ -55,7 +58,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
const currentPkiPath = computed(() => `${currentConfig.value.accountBasePath}/pki`)
//Use pki login controls
- const { login } = usePkiAuth(currentPkiPath as any)
+ const pkiAuth = usePkiAuth(currentPkiPath as any)
+ const { login } = useMfaLogin([ totpMfaProcessor() ])
//We can send post messages to the server heartbeat endpoint to get status
const runHeartbeat = async () => {
@@ -76,15 +80,73 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
}
}
+ const mfaUpgrade = (() => {
+
+ const store = shallowRef<IMfaFlowContinuiation | null>(null)
+ const message = computed<IMfaMessage | null>(() =>{
+ if(!store.value){
+ return null
+ }
+ //clone the continuation to send to the popup
+ const cl = clone<Partial<IMfaFlowContinuiation>>(store.value)
+ //Remove the submit method from the continuation
+ delete cl.submit;
+ return cl as IMfaMessage
+ })
+
+ const { start, stop } = useTimeoutFn(() => set(store, null), 360 * 1000)
+
+ return{
+ setContiuation(cont: IMfaFlowContinuiation){
+ //Store continuation for later
+ set(store, cont)
+ //Restart cleanup timer
+ start()
+ },
+ continuation: message,
+ async submit(submission: IMfaSubmission){
+ const cont = get(store)
+ if(!cont){
+ throw new Error('MFA login expired')
+ }
+ const response = await cont.submit(submission)
+ response.getResultOrThrow()
+
+ //Stop timer
+ stop()
+ //clear the continuation
+ defer(() => set(store, null))
+ }
+ }
+ })()
+
//Configure interval to run every 5 minutes to update the status
setInterval(runHeartbeat, 60 * 1000);
delay(runHeartbeat, 1000) //Delay 1 second to allow the extension to load
return {
- waitForChange: waitForChangeFn([currentConfig, loggedIn, userName]),
- login: popupOnly(async (token: string): Promise<boolean> => {
- //Perform login
- await login(token)
+ waitForChange: waitForChangeFn([currentConfig, loggedIn, userName, mfaUpgrade.continuation]),
+ login: popupOnly(async (usernameOrToken: string, password?: string): Promise<boolean> => {
+
+ if(password){
+ const result = await login(usernameOrToken, password)
+ if ('getResultOrThrow' in result){
+ (result as WebMessage).getResultOrThrow()
+ }
+
+ if((result as IMfaFlowContinuiation).submit){
+ //Capture continuation, store for submission for later, and set the continuation
+ mfaUpgrade.setContiuation(result as IMfaFlowContinuiation);
+ return true;
+ }
+
+ //Otherwise normal login
+ }
+ else{
+ //Perform login
+ await pkiAuth.login(usernameOrToken)
+ }
+
//load profile
getProfile()
return true;
@@ -95,6 +157,19 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
//Cleanup after logout
clearLoginState()
}),
+ submitMfa: popupOnly(async (submission: IMfaSubmission): Promise<boolean> => {
+ const cont = get(mfaUpgrade.continuation)
+ if(!cont || cont.expired){
+ return false;
+ }
+
+ //Submit the continuation
+ await mfaUpgrade.submit(submission);
+
+ //load profile
+ getProfile()
+ return true;
+ }),
getProfile: popupAndOptionsOnly(getProfile),
async getStatus (){
return {
@@ -102,6 +177,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
loggedIn: get(loggedIn),
//username
userName: get(userName),
+ //mfa status
+ mfaStatus: get(mfaUpgrade.continuation)
} as ClientStatus
},
}
@@ -111,7 +188,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => {
'logout',
'getProfile',
'getStatus',
- 'waitForChange'
+ 'waitForChange',
+ 'submitMfa',
]),
}
} \ No newline at end of file
diff --git a/extension/src/features/framework/index.ts b/extension/src/features/framework/index.ts
index 44ae031..2d9cad5 100644
--- a/extension/src/features/framework/index.ts
+++ b/extension/src/features/framework/index.ts
@@ -34,7 +34,11 @@ export type FeatureApi = {
export type SendMessageHandler = <T extends JsonObject | JsonObject[]>(action: string, data: any) => Promise<T>
export type VarArgsFunction<T> = (...args: any[]) => T
export type FeatureConstructor<TState, T extends FeatureApi> = () => IFeatureExport<TState, T>
-export type DummyApiExport<T extends FeatureApi> = Array<keyof T>
+
+export type DummyApiExport<T extends FeatureApi> = {
+ [K in keyof T]: T[K] extends Function ? K : never
+}[keyof T][]
+
export interface IFeatureExport<TState, TFeature extends FeatureApi> {
/**
diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts
index a8ac4e6..d909162 100644
--- a/extension/src/features/identity-api.ts
+++ b/extension/src/features/identity-api.ts
@@ -24,10 +24,10 @@ import {
exportForegroundApi
} from "./framework";
import { AppSettings } from "./settings";
-import { shallowRef, watch } from "vue";
+import { shallowRef } from "vue";
import { useSession } from "@vnuge/vnlib.browser";
-import { set, useToggle } from "@vueuse/core";
-import { defer, isArray } from "lodash";
+import { set, useToggle, watchDebounced } from "@vueuse/core";
+import { isArray } from "lodash";
import { waitForChange, waitForChangeFn } from "./util";
export interface IdentityApi extends FeatureApi, Watchable {
@@ -37,6 +37,7 @@ export interface IdentityApi extends FeatureApi, Watchable {
getAllKeys: () => Promise<NostrPubKey[]>;
getPublicKey: () => Promise<NostrPubKey | undefined>;
selectKey: (key: NostrPubKey) => Promise<void>;
+ refreshKeys: () => Promise<void>;
}
export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
@@ -50,27 +51,23 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
const allKeys = shallowRef<NostrPubKey[]>([]);
const [ onKeyUpdateTriggered , triggerKeyUpdate ] = useToggle()
- const keyLoadWatchLoop = async () => {
- while(true){
- //Load keys from server if logged in
- if(loggedIn.value){
- const [...keys] = await execRequest(Endpoints.GetKeys);
- allKeys.value = isArray(keys) ? keys : [];
- }
- else{
- //Clear all keys when logged out
- allKeys.value = [];
- }
-
- //Wait for changes to trigger a new key-load
- await waitForChange([loggedIn, onKeyUpdateTriggered])
+ watchDebounced([onKeyUpdateTriggered, loggedIn], async () => {
+ //Load keys from server if logged in
+ if (loggedIn.value) {
+ const [...keys] = await execRequest(Endpoints.GetKeys);
+ allKeys.value = isArray(keys) ? keys : [];
}
- }
+ else {
+ //Clear all keys when logged out
+ allKeys.value = [];
- defer(keyLoadWatchLoop)
+ //Clear the selected key if the user becomes logged out
+ selectedKey.value = undefined;
+ }
- //Clear the selected key if the user logs out
- watch(loggedIn, (li) => li ? null : selectedKey.value = undefined)
+ //Wait for changes to trigger a new key-load
+ await waitForChange([ loggedIn, onKeyUpdateTriggered ])
+ }, { debounce: 100 })
return {
//Identity is only available in options context
@@ -98,6 +95,10 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
getPublicKey: (): Promise<NostrPubKey | undefined> => {
return Promise.resolve(selectedKey.value);
},
+ refreshKeys: () => {
+ triggerKeyUpdate()
+ return Promise.resolve()
+ },
waitForChange: waitForChangeFn([selectedKey, loggedIn, allKeys])
}
},
@@ -108,7 +109,8 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => {
'getAllKeys',
'getPublicKey',
'selectKey',
- 'waitForChange'
+ 'waitForChange',
+ 'refreshKeys'
])
}
} \ No newline at end of file
diff --git a/extension/src/features/index.ts b/extension/src/features/index.ts
index c146cef..f05de9b 100644
--- a/extension/src/features/index.ts
+++ b/extension/src/features/index.ts
@@ -17,13 +17,13 @@
export type { NostrPubKey, LoginMessage } from './types'
export type * from './framework'
export type { PluginConfig } from './settings'
-export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './account-api'
+export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './pki-api'
export type { NostrApi } from './nostr-api'
export type { UserApi } from './auth-api'
export type { IdentityApi } from './identity-api'
export { useBackgroundFeatures, useForegoundFeatures } from './framework'
-export { useLocalPki, usePkiApi } from './account-api'
+export { useLocalPki, usePkiApi } from './pki-api'
export { useAuthApi } from './auth-api'
export { useIdentityApi } from './identity-api'
export { useNostrApi } from './nostr-api'
@@ -31,4 +31,5 @@ export { useSettingsApi, useAppSettings } from './settings'
export { useHistoryApi } from './history'
export { useEventTagFilterApi } from './tagfilter-api'
export { useInjectAllowList } from './nip07allow-api'
-export { onWatchableChange } from './util' \ No newline at end of file
+export { onWatchableChange } from './util'
+export { useMfaConfigApi, type MfaUpdateResult } from './mfa-api' \ No newline at end of file
diff --git a/extension/src/features/mfa-api.ts b/extension/src/features/mfa-api.ts
new file mode 100644
index 0000000..fc6d51a
--- /dev/null
+++ b/extension/src/features/mfa-api.ts
@@ -0,0 +1,87 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { get, set, useToggle, watchDebounced } from "@vueuse/core";
+import { computed, shallowRef } from "vue";
+import { } from "lodash";
+import { useSession, useMfaConfig, MfaMethod } from "@vnuge/vnlib.browser";
+import type { TotpUpdateMessage, Watchable } from "./types";
+import type { AppSettings } from "./settings";
+import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, optionsOnly } from "./framework";
+import { waitForChangeFn } from "./util";
+
+export type MfaUpdateResult = TotpUpdateMessage
+
+export interface MfaConfigApi extends FeatureApi, Watchable {
+ getMfaMethods: () => Promise<MfaMethod[]>
+ enableOrUpdate: (method: MfaMethod, password: string) => Promise<MfaUpdateResult>
+ disableMethod: (method: MfaMethod, password: string) => Promise<void>
+ refresh: () => Promise<void>
+}
+
+export const useMfaConfigApi = (): IFeatureExport<AppSettings, MfaConfigApi> => {
+
+ return {
+ background: ({ state }: BgRuntime<AppSettings>): MfaConfigApi => {
+ const { loggedIn } = useSession();
+ const { currentConfig } = state
+
+ const [onRefresh, refresh] = useToggle()
+
+ const mfaPath = computed(() => `${currentConfig.value.accountBasePath}/mfa`)
+ const mfaConfig = useMfaConfig(mfaPath)
+ const mfaEnabledMethods = shallowRef<MfaMethod[]>([])
+
+ //Update enabled methods
+ watchDebounced([currentConfig, loggedIn, onRefresh], async () => {
+ if(!loggedIn.value){
+ set(mfaEnabledMethods, [])
+ return
+ }
+ const methods = await mfaConfig.getMethods()
+ set(mfaEnabledMethods, methods)
+ }, { debounce: 100 })
+
+ return {
+ waitForChange: waitForChangeFn([currentConfig, loggedIn, mfaEnabledMethods]),
+
+ getMfaMethods: optionsOnly(() => {
+ return Promise.resolve(get(mfaEnabledMethods))
+ }),
+ enableOrUpdate: optionsOnly(async (method: MfaMethod, password: string) => {
+ //Exec request to update mfa method
+ const result = await mfaConfig.initOrUpdateMethod<MfaUpdateResult>(method, password)
+ refresh()
+ return result.getResultOrThrow()
+ }),
+ disableMethod: optionsOnly(async (method: MfaMethod, password: string) => {
+ await mfaConfig.disableMethod(method, password)
+ refresh()
+ }),
+ refresh() {
+ refresh()
+ return Promise.resolve()
+ }
+ }
+ },
+ foreground: exportForegroundApi<MfaConfigApi>([
+ 'waitForChange',
+ 'getMfaMethods',
+ 'enableOrUpdate',
+ 'disableMethod',
+ 'refresh'
+ ]),
+ }
+} \ No newline at end of file
diff --git a/extension/src/features/account-api.ts b/extension/src/features/pki-api.ts
index 96948c4..41fbd48 100644
--- a/extension/src/features/account-api.ts
+++ b/extension/src/features/pki-api.ts
@@ -13,15 +13,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-import { useMfaConfig, usePkiConfig, type PkiPublicKey } from "@vnuge/vnlib.browser";
+import { usePkiConfig, type PkiPublicKey, useSession } from "@vnuge/vnlib.browser";
import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers";
import { JsonObject } from "type-fest";
-import { computed, watch } from "vue";
+import { computed, shallowRef } from "vue";
import { JWK, SignJWT, importJWK } from "jose";
import { clone } from "lodash";
-import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly, popupAndOptionsOnly } from "./framework";
+import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly } from "./framework";
import { AppSettings } from "./settings";
-import { set, toRefs } from "@vueuse/core";
+import { get, set, toRefs, useToggle, watchDebounced } from "@vueuse/core";
+import { Watchable } from "./types";
+import { waitForChangeFn } from "./util";
export interface EcKeyParams extends JsonObject {
@@ -39,43 +41,59 @@ export interface PkiPubKey extends JsonObject, PkiPublicKey {
readonly userName: string
}
-export interface PkiApi extends FeatureApi{
+export interface PkiApi extends FeatureApi, Watchable{
getAllKeys(): Promise<PkiPubKey[]>
+ addOrUpdate(key: PkiPubKey): Promise<void>
removeKey(kid: PkiPubKey): Promise<void>
- isEnabled(): Promise<boolean>
+ refresh(): Promise<void>
}
export const usePkiApi = (): IFeatureExport<AppSettings, PkiApi> => {
return{
background: ({ state } : BgRuntime<AppSettings>):PkiApi =>{
+ const { loggedIn } = useSession()
const accountPath = computed(() => state.currentConfig.value.accountBasePath)
- const mfaEndpoint = computed(() => `${accountPath.value}/mfa`)
const pkiEndpoint = computed(() => `${accountPath.value}/pki`)
-
+ const [ onRefresh, refresh ] = useToggle()
//Compute config
- const mfaConfig = useMfaConfig(mfaEndpoint);
- const pkiConfig = usePkiConfig(pkiEndpoint, mfaConfig);
+
+ const pkiConfig = usePkiConfig(pkiEndpoint);
+ const keys = shallowRef<PkiPubKey[]>([])
//Refresh the config when the endpoint changes
- watch(mfaEndpoint, () => pkiConfig.refresh());
+ watchDebounced([pkiEndpoint, loggedIn, onRefresh], async () => {
+ if(!loggedIn.value){
+ set(keys, [])
+ return
+ }
+
+ const res = await pkiConfig.getAllKeys()
+ set(keys, res as PkiPubKey[])
+ }, {debounce: 100});
return{
- getAllKeys: optionsOnly(async () => {
- const res = await pkiConfig.getAllKeys();
- return res as PkiPubKey[]
+ waitForChange: waitForChangeFn([loggedIn, keys]),
+ getAllKeys: optionsOnly(() => {
+ return Promise.resolve(get(keys))
}),
removeKey: optionsOnly(async (key: PkiPubKey) => {
await pkiConfig.removeKey(key.kid)
}),
- isEnabled: popupAndOptionsOnly(async () => {
- return pkiConfig.enabled.value
- })
+ addOrUpdate: optionsOnly(async (key: PkiPubKey) => {
+ await pkiConfig.addOrUpdate(key)
+ }),
+ refresh() {
+ refresh()
+ return Promise.resolve()
+ }
}
},
foreground: exportForegroundApi<PkiApi>([
+ 'waitForChange',
'getAllKeys',
+ 'addOrUpdate',
'removeKey',
- 'isEnabled'
+ 'refresh'
])
}
}
diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts
index 856b95a..92cf6cf 100644
--- a/extension/src/features/types.ts
+++ b/extension/src/features/types.ts
@@ -63,6 +63,7 @@ export interface LoginMessage extends JsonObject {
export interface ClientStatus extends JsonObject {
readonly loggedIn: boolean;
readonly userName: string | null;
+ readonly mfaStatus: object | null;
}
export enum NostrRelayFlags {
@@ -96,4 +97,12 @@ export interface Watchable{
* must be called again to listen for the next change.
*/
waitForChange(): Promise<void>;
+}
+
+export interface TotpUpdateMessage extends JsonObject {
+ readonly issuer: string
+ readonly digits: number
+ readonly period: number
+ readonly algorithm: string
+ readonly secret: string
} \ No newline at end of file