From b2ebc2992bbb921597d8900350d7b6d4cb9c6b6d Mon Sep 17 00:00:00 2001 From: Trent Larson <trent@trentlarson.com> Date: Fri, 19 Jul 2024 12:44:54 -0600 Subject: [PATCH 1/2] cache the passkey JWANT access token for multiple signatures --- src/db/tables/settings.ts | 5 +- src/libs/crypto/index.ts | 2 +- src/libs/endorserServer.ts | 52 ++++++- src/libs/util.ts | 16 +- src/views/AccountViewView.vue | 250 +++++++++++++------------------ src/views/ContactAmountsView.vue | 15 +- src/views/GiftedDetails.vue | 10 +- src/views/NewEditProjectView.vue | 24 +-- src/views/ProjectViewView.vue | 6 - src/views/ProjectsView.vue | 28 ++-- src/views/SharedPhotoView.vue | 7 +- 11 files changed, 196 insertions(+), 219 deletions(-) diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index ac5cde9..35c428f 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -26,6 +26,7 @@ export type Settings = { lastName?: string; // deprecated - put all names in firstName lastNotifiedClaimId?: string; lastViewedClaimId?: string; + passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes profileImageUrl?: string; reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders reminderOn?: boolean; // Toggle to enable or disable reminders @@ -46,7 +47,7 @@ export type Settings = { }; export function isAnyFeedFilterOn(settings: Settings): boolean { - return !!(settings.filterFeedByNearby || settings.filterFeedByVisible); + return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible); } /** @@ -60,3 +61,5 @@ export const SettingsSchema = { * Constants. */ export const MASTER_SETTINGS_KEY = 1; + +export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index 862ed54..2ac8aef 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -85,7 +85,7 @@ export const generateSeed = (): string => { }; /** - * Retreive an access token + * Retrieve an access token, or "" if no DID is provided. * * @return {*} */ diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 26954cd..754766b 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -5,9 +5,10 @@ import * as R from "ramda"; import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; import { accessToken } from "@/libs/crypto"; -import { NonsensitiveDexie } from "@/db/index"; -import { getAccount } from "@/libs/util"; +import { db, NonsensitiveDexie } from "@/db/index"; +import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util"; import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; +import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; // the object in RegisterAction claims @@ -447,12 +448,57 @@ export function didInfo( return didInfoForContact(did, activeDid, contact, allMyDids).displayName; } +let passkeyAccessToken: string = ""; +let passkeyTokenExpirationEpochSeconds: number = 0; + +export function clearPasskeyToken() { + passkeyAccessToken = ""; + passkeyTokenExpirationEpochSeconds = 0; +} + +export function tokenExpiryTimeDescription() { + if ( + !passkeyAccessToken || + passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000 + ) { + return "Token has expired"; + } else { + return ( + "Token expires at " + + new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString() + ); + } +} + +/** + * Get the headers for a request, potentially including Authorization + */ export async function getHeaders(did?: string) { const headers: { "Content-Type": string; Authorization?: string } = { "Content-Type": "application/json", }; if (did) { - const token = await accessToken(did); + let token; + const account = await getAccount(did); + if (account?.passkeyCredIdHex) { + if ( + passkeyAccessToken && + passkeyTokenExpirationEpochSeconds > Date.now() / 1000 + ) { + // there's an active current passkey token + token = passkeyAccessToken; + } else { + // there's no current passkey token or it's expired + token = await accessToken(did); + + passkeyAccessToken = token; + const passkeyExpirationSeconds = await getPasskeyExpirationSeconds(); + passkeyTokenExpirationEpochSeconds = + Date.now() / 1000 + passkeyExpirationSeconds; + } + } else { + token = await accessToken(did); + } headers["Authorization"] = "Bearer " + token; } else { // it's often OK to request without auth; we assume necessary checks are done earlier diff --git a/src/libs/util.ts b/src/libs/util.ts index c0292ac..c652683 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -1,21 +1,20 @@ // many of these are also found in endorser-mobile utility.ts import axios, { AxiosResponse } from "axios"; -import { IIdentifier } from "@veramo/core"; import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { Account } from "@/db/tables/accounts"; -import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; +import {DEFAULT_PASSKEY_EXPIRATION_MINUTES, MASTER_SETTINGS_KEY} from "@/db/tables/settings"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer"; import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer"; import { Buffer } from "buffer"; -import {KeyMeta} from "@/libs/crypto/vc"; -import {createPeerDid} from "@/libs/crypto/vc/didPeer"; +import { KeyMeta } from "@/libs/crypto/vc"; +import { createPeerDid } from "@/libs/crypto/vc/didPeer"; export const PRIVACY_MESSAGE = "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; @@ -273,6 +272,15 @@ export const registerSaveAndActivatePasskey = async ( return account; }; +export const getPasskeyExpirationSeconds = async (): Promise<number> => { + await db.open(); + const settings = await db.settings.get(MASTER_SETTINGS_KEY); + const passkeyExpirationSeconds = + (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * + 60; + return passkeyExpirationSeconds; +}; + export const sendTestThroughPushServer = async ( subscriptionJSON: PushSubscriptionJSON, skipFilter: boolean, diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index d32267e..2f99791 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -152,7 +152,7 @@ <div class="text-blue-500 text-sm font-bold"> <router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }"> - Activity + Your Activity </router-link> </div> </div> @@ -216,7 +216,6 @@ <div class="mb-2 font-bold">Location</div> <router-link :to="{ name: 'search-area' }" - v-if="activeDid" class="block w-full text-center text-m bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6" > Set Search Area… @@ -622,6 +621,26 @@ </button> </div> + <div class="flex justify-between"> + <span> + <span class="text-slate-500 text-sm font-bold mb-2"> + Passkey Expiration Minutes + </span> + <br /> + <span class="text-sm ml-2"> + {{ passkeyExpirationDescription }} + </span> + </span> + <div class="relative ml-2"> + <input + type="number" + class="border border-slate-400 rounded px-2 py-2 text-center w-20" + v-model="passkeyExpirationMinutes" + @change="updatePasskeyExpiration" + /> + </div> + </div> + <label for="toggleShowGeneralAdvanced" class="flex items-center justify-between cursor-pointer mt-4" @@ -667,7 +686,7 @@ import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; import QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; import { - AppString, + AppString, DEFAULT_ENDORSER_API_SERVER, DEFAULT_IMAGE_API_SERVER, DEFAULT_PUSH_SERVER, IMAGE_TYPE_PROFILE, @@ -675,14 +694,20 @@ import { } from "@/constants/app"; import { db, accountsDB } from "@/db/index"; import { Account } from "@/db/tables/accounts"; -import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; import { + DEFAULT_PASSKEY_EXPIRATION_MINUTES, + MASTER_SETTINGS_KEY, + Settings, +} from "@/db/tables/settings"; +import { + clearPasskeyToken, ErrorResponse, EndorserRateLimits, - ImageRateLimits, fetchEndorserRateLimits, fetchImageRateLimits, + getHeaders, + ImageRateLimits, + tokenExpiryTimeDescription, } from "@/libs/endorserServer"; import { getAccount } from "@/libs/util"; @@ -713,6 +738,9 @@ export default class AccountViewView extends Vue { limitsMessage = ""; loadingLimits = false; notificationMaybeChanged = false; + passkeyExpirationDescription = ""; + passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; + previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES; profileImageUrl?: string; publicHex = ""; publicBase64 = ""; @@ -745,12 +773,32 @@ export default class AccountViewView extends Vue { await this.initializeState(); await this.processIdentity(); + this.passkeyExpirationDescription = tokenExpiryTimeDescription(); + + /** + * Beware! I've seen where this "ready" never resolves. + */ const registration = await navigator.serviceWorker.ready; this.subscription = await registration.pushManager.getSubscription(); this.isSubscribed = !!this.subscription; + console.log("Got to the end of 'mounted' call."); + /** + * Beware! I've seen where we never get to this point because "ready" never resolves. + */ } catch (error) { - console.error("Mount error:", error); - this.handleError(error); + console.error( + "Telling user to clear cache at page create because:", + error, + ); + this.$notify( + { + group: "alert", + type: "danger", + title: "Error Loading Account", + text: "Clear your cache and start over (after data backup).", + }, + -1, + ); } } @@ -780,6 +828,10 @@ export default class AccountViewView extends Vue { this.showContactGives = !!settings?.showContactGivesInline; this.hideRegisterPromptOnNewContact = !!settings?.hideRegisterPromptOnNewContact; + this.passkeyExpirationMinutes = + (settings?.passkeyExpirationMinutes as number) ?? + DEFAULT_PASSKEY_EXPIRATION_MINUTES; + this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes; this.showGeneralAdvanced = !!settings?.showGeneralAdvanced; this.showShortcutBvc = !!settings?.showShortcutBvc; this.warnIfProdServer = !!settings?.warnIfProdServer; @@ -835,11 +887,11 @@ export default class AccountViewView extends Vue { this.publicHex = identity.keys[0].publicKeyHex; this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); this.derivationPath = identity.keys[0].meta?.derivationPath as string; - this.checkLimitsFor(this.activeDid); + await this.checkLimitsFor(this.activeDid); } else if (account?.publicKeyHex) { this.publicHex = account.publicKeyHex as string; this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); - this.checkLimitsFor(this.activeDid); + await this.checkLimitsFor(this.activeDid); } } @@ -868,75 +920,18 @@ export default class AccountViewView extends Vue { this.notificationMaybeChanged = true; } - /** - * Handles errors and updates the component's state accordingly. - * @param {Error} err - The error object. - */ - handleError(err: unknown) { - if ( - err instanceof Error && - err.message === - "Attempted to load account records with no identifier available." - ) { - this.limitsMessage = "No identifier."; - } else { - console.error("Telling user to clear cache at page create because:", err); - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Loading Account", - text: "Clear your cache and start over (after data backup).", - }, - -1, - ); - } - } - public async updateShowContactAmounts() { - try { - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { - showContactGivesInline: this.showContactGives, - }); - } catch (err) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Updating Contact Setting", - text: "The setting may not have saved. Try again, maybe after restarting the app.", - }, - -1, - ); - console.error( - "Telling user to try again after contact-amounts setting update because:", - err, - ); - } + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + showContactGivesInline: this.showContactGives, + }); } public async updateShowGeneralAdvanced() { - try { - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { - showGeneralAdvanced: this.showGeneralAdvanced, - }); - } catch (err) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Updating Advanced Setting", - text: "The setting may not have saved. Try again, maybe after restarting the app.", - }, - -1, - ); - console.error( - "Telling user to try again after general-advanced setting update because:", - err, - ); - } + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + showGeneralAdvanced: this.showGeneralAdvanced, + }); } public async updateWarnIfProdServer(newSetting: boolean) { @@ -963,71 +958,35 @@ export default class AccountViewView extends Vue { } public async updateWarnIfTestServer(newSetting: boolean) { - try { - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { - warnIfTestServer: newSetting, - }); - } catch (err) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Updating Test Warning", - text: "The setting may not have saved. Try again, maybe after restarting the app.", - }, - -1, - ); - console.error( - "Telling user to try again after test-server-warning setting update because:", - err, - ); - } + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + warnIfTestServer: newSetting, + }); } public async toggleHideRegisterPromptOnNewContact() { const newSetting = !this.hideRegisterPromptOnNewContact; - try { - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { - hideRegisterPromptOnNewContact: newSetting, - }); - this.hideRegisterPromptOnNewContact = newSetting; - } catch (err) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Updating Setting", - text: "The setting may not have saved. Try again, maybe after restarting the app.", - }, - -1, - ); - console.error("Telling user to try again because:", err); - } + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + hideRegisterPromptOnNewContact: newSetting, + }); + this.hideRegisterPromptOnNewContact = newSetting; + } + + public async updatePasskeyExpiration() { + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + passkeyExpirationMinutes: this.passkeyExpirationMinutes, + }); + clearPasskeyToken(); + this.passkeyExpirationDescription = tokenExpiryTimeDescription(); } public async updateShowShortcutBvc(newSetting: boolean) { - try { - await db.open(); - await db.settings.update(MASTER_SETTINGS_KEY, { - showShortcutBvc: newSetting, - }); - } catch (err) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error Updating BVC Shortcut Setting", - text: "The setting may not have saved. Try again, maybe after restarting the app.", - }, - -1, - ); - console.error( - "Telling user to try again after BVC-shortcut setting update because:", - err, - ); - } + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + showShortcutBvc: newSetting, + }); } /** @@ -1220,7 +1179,7 @@ export default class AccountViewView extends Vue { // the user was not known to be registered, but now they are (because we got no error) so let's record it try { await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { isRegistered: true, }); this.isRegistered = true; @@ -1247,7 +1206,7 @@ export default class AccountViewView extends Vue { try { await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { isRegistered: false, }); this.isRegistered = false; @@ -1272,8 +1231,8 @@ export default class AccountViewView extends Vue { (data?.error?.message as string) || "Bad server response."; console.error( "Got bad response retrieving limits, which usually means user isn't registered.", + error, ); - //console.error(error); } else { this.limitsMessage = "Got an error retrieving limits."; console.error("Got some error retrieving limits:", error); @@ -1350,7 +1309,7 @@ export default class AccountViewView extends Vue { async onClickSaveApiServer() { await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { apiServer: this.apiServerInput, }); this.apiServer = this.apiServerInput; @@ -1358,7 +1317,7 @@ export default class AccountViewView extends Vue { async onClickSavePushServer() { await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { webPushServer: this.webPushServerInput, }); this.webPushServer = this.webPushServerInput; @@ -1377,7 +1336,7 @@ export default class AccountViewView extends Vue { (this.$refs.imageMethodDialog as ImageMethodDialog).open( async (imgUrl) => { await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { profileImageUrl: imgUrl, }); this.profileImageUrl = imgUrl; @@ -1407,16 +1366,13 @@ export default class AccountViewView extends Vue { return; } try { - const token = await accessToken(this.activeDid); + const headers = await getHeaders(this.activeDid); + this.passkeyExpirationDescription = tokenExpiryTimeDescription(); const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.profileImageUrl), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, + { headers }, ); if (response.status === 204) { // don't bother with a notification @@ -1436,7 +1392,7 @@ export default class AccountViewView extends Vue { } await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { profileImageUrl: undefined, }); @@ -1448,7 +1404,7 @@ export default class AccountViewView extends Vue { console.error("The image was already deleted:", error); await db.open(); - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { profileImageUrl: undefined, }); diff --git a/src/views/ContactAmountsView.vue b/src/views/ContactAmountsView.vue index 157f243..f54ad16 100644 --- a/src/views/ContactAmountsView.vue +++ b/src/views/ContactAmountsView.vue @@ -55,7 +55,7 @@ {{ new Date(record.issuedAt).toLocaleString() }} </td> <td class="p-1"> - <span v-if="record.agentDid == contact.did"> + <span v-if="record.agentDid == contact?.did"> <div class="font-bold"> {{ displayAmount(record.unit, record.amount) }} <span v-if="record.amountConfirmed" title="Confirmed"> @@ -71,7 +71,7 @@ </span> </td> <td class="p-1"> - <span v-if="record.agentDid == contact.did"> + <span v-if="record.agentDid == contact?.did"> <fa icon="arrow-left" class="text-slate-400 fa-fw" /> </span> <span v-else> @@ -79,7 +79,7 @@ </span> </td> <td class="p-1"> - <span v-if="record.agentDid != contact.did"> + <span v-if="record.agentDid != contact?.did"> <div class="font-bold"> {{ displayAmount(record.unit, record.amount) }} <span v-if="record.amountConfirmed" title="Confirmed"> @@ -105,7 +105,7 @@ </template> <script lang="ts"> -import { AxiosError } from "axios"; +import { AxiosError, AxiosRequestHeaders } from "axios"; import * as R from "ramda"; import { Component, Vue } from "vue-facing-decorator"; @@ -114,7 +114,6 @@ import { NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; import { AgreeVerifiableCredential, createEndorserJwtVcFromClaim, @@ -271,11 +270,7 @@ export default class ContactAmountssView extends Vue { // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; - const token = await accessToken(this.activeDid); - const headers = { - "Content-Type": "application/json", - Authorization: "Bearer " + token, - }; + const headers = getHeaders(this.activeDid) as AxiosRequestHeaders; try { const resp = await this.axios.post(url, payload, { headers }); diff --git a/src/views/GiftedDetails.vue b/src/views/GiftedDetails.vue index 626a0c8..5f4a9f4 100644 --- a/src/views/GiftedDetails.vue +++ b/src/views/GiftedDetails.vue @@ -186,10 +186,10 @@ import { constructGive, createAndSubmitGive, didInfo, + getHeaders, getPlanFromCache, } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; -import { accessToken } from "@/libs/crypto"; import { Contact } from "@/db/tables/contacts"; @Component({ @@ -380,16 +380,12 @@ export default class GiftedDetails extends Vue { return; } try { - const token = await accessToken(this.activeDid); + const headers = await getHeaders(this.activeDid); const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.imageUrl), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, + { headers }, ); if (response.status === 204) { // don't bother with a notification diff --git a/src/views/NewEditProjectView.vue b/src/views/NewEditProjectView.vue index 0ebf62a..e4f6c88 100644 --- a/src/views/NewEditProjectView.vue +++ b/src/views/NewEditProjectView.vue @@ -173,7 +173,7 @@ <script lang="ts"> import "leaflet/dist/leaflet.css"; -import { AxiosError } from "axios"; +import { AxiosError, AxiosRequestHeaders } from "axios"; import { DateTime } from "luxon"; import { Component, Vue } from "vue-facing-decorator"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; @@ -183,9 +183,9 @@ import QuickNav from "@/components/QuickNav.vue"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; import { createEndorserJwtVcFromClaim, + getHeaders, PlanVerifiableCredential, } from "@/libs/endorserServer"; import { useAppStore } from "@/store/app"; @@ -250,11 +250,7 @@ export default class NewEditProjectView extends Vue { this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(this.projectId); - const token = await accessToken(userDid); - const headers = { - "Content-Type": "application/json", - Authorization: "Bearer " + token, - }; + const headers = await getHeaders(userDid); try { const resp = await this.axios.get(url, { headers }); @@ -309,16 +305,12 @@ export default class NewEditProjectView extends Vue { return; } try { - const token = await accessToken(this.activeDid); + const headers = getHeaders(this.activeDid) as AxiosRequestHeaders; const response = await this.axios.delete( DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(this.imageUrl), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, + { headers }, ); if (response.status === 204) { // don't bother with a notification @@ -418,11 +410,7 @@ export default class NewEditProjectView extends Vue { const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = this.apiServer + "/api/v2/claim"; - const token = await accessToken(issuerDid); - const headers = { - "Content-Type": "application/json", - Authorization: "Bearer " + token, - }; + const headers = await getHeaders(issuerDid); try { const resp = await this.axios.post(url, payload, { headers }); diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index dc37032..6843d19 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -416,7 +416,6 @@ import { accountsDB, db } from "@/db/index"; import { Account } from "@/db/tables/accounts"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; import * as libsUtil from "@/libs/util"; import { BLANK_GENERIC_SERVER_RECORD, @@ -583,11 +582,6 @@ export default class ProjectViewView extends Vue { this.loadPlanFulfillersTo(); - // now load fulfilled-by, a single project - if (this.activeDid) { - const token = await accessToken(this.activeDid); - headers["Authorization"] = "Bearer " + token; - } const fulfilledByUrl = this.apiServer + "/api/v2/report/planFulfilledByPlan?planHandleId=" + diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index d8a1f96..c967e2b 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -233,13 +233,16 @@ import { Component, Vue } from "vue-facing-decorator"; import { NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; import * as libsUtil from "@/libs/util"; import InfiniteScroll from "@/components/InfiniteScroll.vue"; import QuickNav from "@/components/QuickNav.vue"; import ProjectIcon from "@/components/ProjectIcon.vue"; import TopMessage from "@/components/TopMessage.vue"; -import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer"; +import { + getHeaders, + OfferSummaryRecord, + PlanData, +} from "@/libs/endorserServer"; import EntityIcon from "@/components/EntityIcon.vue"; @Component({ @@ -293,13 +296,9 @@ export default class ProjectsView extends Vue { * @param url the url used to fetch the data * @param token Authorization token **/ - async projectDataLoader(url: string, token: string) { - const headers: { [key: string]: string } = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; - + async projectDataLoader(url: string) { try { + const headers = await getHeaders(this.activeDid); this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); if (resp.status === 200 && resp.data.data) { @@ -353,8 +352,7 @@ export default class ProjectsView extends Vue { **/ async loadProjects(activeDid?: string, urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; - const token: string = await accessToken(activeDid); - await this.projectDataLoader(url, token); + await this.projectDataLoader(url); } /** @@ -392,11 +390,8 @@ export default class ProjectsView extends Vue { * @param url the url used to fetch the data * @param token Authorization token **/ - async offerDataLoader(url: string, token: string) { - const headers: { [key: string]: string } = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; + async offerDataLoader(url: string) { + const headers = getHeaders(this.activeDid); try { this.isLoading = true; @@ -454,8 +449,7 @@ export default class ProjectsView extends Vue { **/ async loadOffers(issuerDid?: string, urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`; - const token: string = await accessToken(issuerDid); - await this.offerDataLoader(url, token); + await this.offerDataLoader(url); } public computedOfferTabClassNames() { diff --git a/src/views/SharedPhotoView.vue b/src/views/SharedPhotoView.vue index 5c427a6..1f58aa0 100644 --- a/src/views/SharedPhotoView.vue +++ b/src/views/SharedPhotoView.vue @@ -65,7 +65,7 @@ import { } from "@/constants/app"; import { db } from "@/db/index"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; +import { getHeaders } from "@/libs/endorserServer"; @Component({ components: { PhotoDialog, QuickNav } }) export default class SharedPhotoView extends Vue { @@ -151,10 +151,7 @@ export default class SharedPhotoView extends Vue { let result; try { // send the image to the server - const token = await accessToken(this.activeDid); - const headers = { - Authorization: "Bearer " + token, - }; + const headers = await getHeaders(this.activeDid); const formData = new FormData(); formData.append( "image", From 4270374a674a83251df5c78a007e83718e5d6edd Mon Sep 17 00:00:00 2001 From: Trent Larson <trent@trentlarson.com> Date: Fri, 19 Jul 2024 20:49:43 -0600 Subject: [PATCH 2/2] create an identifier by default, while letting them choose if passkeys are enabled --- src/libs/crypto/vc/passkeyDidPeer.ts | 10 +++- src/views/HelpView.vue | 9 ++-- src/views/HomeView.vue | 77 +++++++++++----------------- src/views/StartView.vue | 7 ++- 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/src/libs/crypto/vc/passkeyDidPeer.ts b/src/libs/crypto/vc/passkeyDidPeer.ts index 920d751..5efc372 100644 --- a/src/libs/crypto/vc/passkeyDidPeer.ts +++ b/src/libs/crypto/vc/passkeyDidPeer.ts @@ -411,13 +411,21 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { } // this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types // (another reference is the @aviarytech/did-peer resolver) + + /** + * Looks like JsonWebKey2020 isn't too difficult: + * - change context security/suites link to jws-2020/v1 + * - change publicKeyMultibase to publicKeyJwk generated with cborToKeys + * - change type to JsonWebKey2020 + */ + const id = did.split(":")[2]; const multibase = id.slice(1); const encnumbasis = multibase.slice(1); const didDocument = { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/jws-2020/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", ], assertionMethod: [did + "#" + encnumbasis], authentication: [did + "#" + encnumbasis], diff --git a/src/views/HelpView.vue b/src/views/HelpView.vue index ea6d3b5..02327fa 100644 --- a/src/views/HelpView.vue +++ b/src/views/HelpView.vue @@ -24,16 +24,15 @@ <!-- eslint-disable prettier/prettier --> <div> <p> - This app is a window into data that you and your friends own, focused on - gifts and collaboration. + This app focuses on gifts & gratitude, using them to build cool things with your network. </p> <h2 class="text-xl font-semibold">What is the idea here?</h2> <p> We are building networks of people who want to grow a giving society. - First of all, you can see what people have given, and also recognize - gifts you've seen, in a way that leaves a permanent record -- one that - came from you, and the recipient can prove it was for them. This is + First of all, let's build gratitude: see what people have given, and recognize + gifts you've seen. This is done in a way that leaves a permanent record -- one that + came from you, and that the recipient can prove it was for them. This is personally gratifying, but it extends to broader work: volunteers get confirmation of activity, and selectively show off their contributions and network. diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 53ac742..25e4ead 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -77,58 +77,28 @@ <div v-else> <!-- !isCreatingIdentifier --> - <div - v-if="!activeDid" - class="bg-amber-200 rounded-md text-center px-4 py-3 mb-4" - > - <div v-if="PASSKEYS_ENABLED"> - <p class="text-lg mb-3"> - Choose how to see info from your contacts or share contributions: - </p> - <div class="flex justify-between"> - <button - class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" - @click="generateIdentifier()" - > - Let me start the easiest (with a passkey). - </button> - <router-link - :to="{ name: 'start' }" - class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" - > - Give me all the options. - </router-link> - </div> - </div> - <div v-else> - <p class="text-lg mb-3"> - To recognize giving or collaborate, have someone register you: - </p> - <router-link - :to="{ name: 'contact-qr' }" - class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" - > - Share your contact info. - </router-link> - </div> - </div> - - <div v-else class="mb-4"> - <!-- activeDid --> - + <!-- They should have an identifier, even if it's an auto-generated one that they'll never use. --> + <div class="mb-4"> <div v-if="!isRegistered" class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4" > <!-- activeDid && !isRegistered --> - Someone must register you before you can give kudos or make offers - or create projects... basically before doing anything. + To share, someone must register you. <router-link :to="{ name: 'contact-qr' }" class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" > - Show Them Your Identifier Info + Show Them Default Identifier Info </router-link> + <div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full"> + <router-link + :to="{ name: 'start' }" + class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" + > + See all your options first + </router-link> + </div> </div> <div v-else> @@ -340,7 +310,11 @@ import FeedFilters from "@/components/FeedFilters.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue"; import QuickNav from "@/components/QuickNav.vue"; import TopMessage from "@/components/TopMessage.vue"; -import { AppString, NotificationIface, PASSKEYS_ENABLED } from "@/constants/app"; +import { + AppString, + NotificationIface, + PASSKEYS_ENABLED, +} from "@/constants/app"; import { db, accountsDB } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { @@ -359,7 +333,10 @@ import { GiverReceiverInputInfo, GiveSummaryRecord, } from "@/libs/endorserServer"; -import { registerSaveAndActivatePasskey } from "@/libs/util"; +import { + generateSaveAndActivateIdentity, + registerSaveAndActivatePasskey, +} from "@/libs/util"; interface GiveRecordWithContactInfo extends GiveSummaryRecord { giver: { @@ -423,7 +400,14 @@ export default class HomeView extends Vue { try { await accountsDB.open(); const allAccounts = await accountsDB.accounts.toArray(); - this.allMyDids = allAccounts.map((acc) => acc.did); + if (allAccounts.length > 0) { + this.allMyDids = allAccounts.map((acc) => acc.did); + } else { + this.isCreatingIdentifier = true; + const newDid = await generateSaveAndActivateIdentity(); + this.isCreatingIdentifier = false; + this.allMyDids = [newDid]; + } await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; @@ -440,6 +424,7 @@ export default class HomeView extends Vue { this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); + // someone may have have registered after sharing contact info, so recheck if (!this.isRegistered && this.activeDid) { try { @@ -481,7 +466,7 @@ export default class HomeView extends Vue { } } - async generateIdentifier() { + async generatePasskeyIdentifier() { this.isCreatingIdentifier = true; const account = await registerSaveAndActivatePasskey( AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""), diff --git a/src/views/StartView.vue b/src/views/StartView.vue index 54587d9..002db61 100644 --- a/src/views/StartView.vue +++ b/src/views/StartView.vue @@ -27,7 +27,7 @@ <p class="text-center text-xl font-light"> How do you want to create this identifier? </p> - <p class="text-center font-light mt-6"> + <p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6"> A <strong>passkey</strong> is easy to manage, though it is less interoperable with other systems for advanced uses. <a @@ -49,6 +49,7 @@ </p> <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4"> <a + v-if="PASSKEYS_ENABLED" @click="onClickNewPasskey()" class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer" > @@ -88,7 +89,7 @@ <script lang="ts"> import { Component, Vue } from "vue-facing-decorator"; -import { AppString } from "@/constants/app"; +import { AppString, PASSKEYS_ENABLED } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { registerSaveAndActivatePasskey } from "@/libs/util"; @@ -97,6 +98,8 @@ import { registerSaveAndActivatePasskey } from "@/libs/util"; components: {}, }) export default class StartView extends Vue { + PASSKEYS_ENABLED = PASSKEYS_ENABLED; + givenName = ""; numAccounts = 0;