diff --git a/vtc-service/admin-ui/src/lib/wallet.ts b/vtc-service/admin-ui/src/lib/wallet.ts index 2ab4d2f0..68ba4ce3 100644 --- a/vtc-service/admin-ui/src/lib/wallet.ts +++ b/vtc-service/admin-ui/src/lib/wallet.ts @@ -84,11 +84,31 @@ interface VtaWalletProvider { secretKind?: SecretKind; }): Promise; proxyLogin?(params: { - entryId: string; + entryId?: string; nonce?: string; target?: { kind: string; [k: string]: unknown }; ttlSecondsHint?: number; }): Promise; + /** Which persona this site knows the user as, resolving or binding one. + * Mints nothing. Present from the wallet build that added first-use + * persona binding (OpenVTC/vta-browser-plugin#145). */ + walletProfile?(params: { + target?: { kind: string; [k: string]: unknown }; + }): Promise; +} + +interface WalletProfileWireResult { + /** The persona DID this VTC knows the operator as. `/auth/challenge` is + * bound to it, so it has to be known before a nonce can be asked for. */ + did: string; + /** The vault entry backing it — passed straight to `proxyLogin` so the + * wallet does not repeat the lookup it just did. */ + entryId: string; + /** True when the wallet bound this persona just now, i.e. the operator was + * prompted. The VTC has never seen it, so the sign-in that follows will be + * refused until the DID is on the ACL — which is worth saying plainly + * rather than surfacing as an opaque 403. */ + bound: boolean; } declare global { @@ -115,6 +135,18 @@ export function isWalletProxyAvailable(): boolean { ); } +/** True iff the wallet can resolve-or-bind a persona for this origin itself. + * + * A capability probe, not a compatibility fold: without it the proxy path can + * only work for an operator who has already bound an entry by hand, and the + * difference is worth an accurate message rather than a `TypeError`. */ +export function isWalletProfileAvailable(): boolean { + return ( + isWalletProxyAvailable() && + typeof window.vtaWallet?.walletProfile === "function" + ); +} + /** API base for the wallet's auth round-trip. Points at the VTC's * header-exempt wallet surface, served same-origin with the admin UI. */ function walletApiBase(): string { @@ -191,6 +223,63 @@ export async function loginWithWalletProxy( "Chosen entry has no principal DID — only did-self-issued entries can proxy-login.", ); } + return runProxySiop(entry.principalDid, entry.id); +} + +/** + * The preferred VTA-proxied sign-in: let the wallet say which persona this + * VTC knows the operator as, then run the round-trip as that persona. + * + * Why this and not `listProxyCandidates()` first — the flow it replaces asked + * the wallet to enumerate *every* vault entry pinned to this VTC in order to + * find one, which is a disclosure of the operator's vault to answer a question + * about a single entry, and on a fresh wallet it returned nothing and dead-ended + * with "add an entry, then retry". The wallet now owns that question: it + * resolves the entry for this origin, or asks the operator to choose a persona + * and remembers the answer. + * + * The persona DID has to be known *before* the challenge, because + * `/auth/challenge` is bound to it — which is why this cannot be folded into + * `proxyLogin` as a single call. + */ +export async function loginWithWalletProfile(): Promise { + if (!isWalletProfileAvailable()) { + throw new Error( + "This VTA wallet build cannot choose an identity for a site. " + + "Update the extension, or pin a did-self-issued vault entry to this VTC by hand.", + ); + } + const rp = await rpDid(); + const profile = await window.vtaWallet!.walletProfile!({ + target: { kind: "did", did: rp }, + }); + if (!profile.did || !profile.entryId) { + throw new Error("wallet returned no identity for this site"); + } + try { + return await runProxySiop(profile.did, profile.entryId); + } catch (err) { + if (!profile.bound) throw err; + // The persona was created a moment ago, so this VTC has never seen it and + // the ACL gate in `handle_challenge` is by far the likeliest cause. Say + // which DID needs admitting: the operator cannot act on a 403 alone, and + // the DID is not otherwise on screen anywhere. + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `${message}\n\nThis was the first sign-in as ${profile.did}. ` + + "If the VTC refused it, that DID needs an Admin entry in this VTC's ACL — " + + `ask another admin to run \`vtc admin invite --did ${profile.did}\`.`, + ); + } +} + +/** Challenge → mint → authenticate, as a known persona. Shared by the + * wallet-resolved path and the hand-picked-entry path, so both apply the same + * rule and the same error handling. */ +async function runProxySiop( + principalDid: string, + entryId: string, +): Promise { const rp = await rpDid(); const base = walletApiBase().replace(/\/+$/, ""); @@ -199,7 +288,7 @@ export async function loginWithWalletProxy( method: "POST", headers: { "content-type": "application/json" }, credentials: "include", - body: JSON.stringify({ did: entry.principalDid }), + body: JSON.stringify({ did: principalDid }), }); if (!chRes.ok) { // Carry the daemon's message, not just the code. A 403 here is the ACL @@ -221,7 +310,7 @@ export async function loginWithWalletProxy( // 2. VTA mints the SIOP id_token (long-term key stays in the VTA). const pl = await window.vtaWallet!.proxyLogin!({ - entryId: entry.id, + entryId, nonce: ch.challenge, target: { kind: "did", did: rp }, }); @@ -259,6 +348,6 @@ export async function loginWithWalletProxy( accessToken: tokenResp.tokens.accessToken, refreshToken: tokenResp.tokens.refreshToken ?? "", sessionId: tokenResp.session.id, - holderDid: entry.principalDid, + holderDid: principalDid, }; } diff --git a/vtc-service/admin-ui/src/pages/Login.tsx b/vtc-service/admin-ui/src/pages/Login.tsx index 0c6f558e..ad76ef97 100644 --- a/vtc-service/admin-ui/src/pages/Login.tsx +++ b/vtc-service/admin-ui/src/pages/Login.tsx @@ -22,9 +22,11 @@ import { } from "@/lib/webauthn"; import { isWalletAvailable, + isWalletProfileAvailable, isWalletProxyAvailable, listProxyCandidates, loginWithWallet, + loginWithWalletProfile, loginWithWalletProxy, type ProxyVaultEntry, } from "@/lib/wallet"; @@ -52,6 +54,7 @@ export function Login() { const walletAvailable = isWalletAvailable(); const proxyAvailable = isWalletProxyAvailable(); + const profileAvailable = isWalletProfileAvailable(); const busy = phase.kind === "running" || walletPhase.kind === "running"; // Shared success tail: the wallet returned a bearer; mirror it into the @@ -178,7 +181,29 @@ export function Login() { } }; + // The wallet owns "which identity does this VTC know me as". It resolves the + // entry for this origin, or — on a first sign-in — asks the operator to pick + // a persona and remembers it. The flow this replaces asked the wallet to + // enumerate every entry pinned to this VTC just to find one, and on a fresh + // wallet returned nothing and dead-ended with "add an entry, then retry". const handleProxyStart = async () => { + setWalletPhase({ kind: "running" }); + setCandidates(null); + try { + const result = await loginWithWalletProfile(); + await finishWithBearer(result.accessToken); + } catch (err) { + const e = err as { message?: string }; + setWalletPhase({ kind: "error", message: e.message ?? String(err) }); + } + }; + + // Escape hatch, not the default: pick from the entries pinned to this VTC. + // Kept because an operator may hold more than one persona here — an Admin and + // a member identity, say — and the wallet's own answer is the one bound to + // this origin. It stays behind an explicit click because reaching it costs a + // consent prompt that enumerates the vault to this page. + const handleChooseIdentity = async () => { setWalletPhase({ kind: "running" }); setCandidates(null); try { @@ -187,7 +212,7 @@ export function Login() { setWalletPhase({ kind: "error", message: "No did-self-issued vault entry is pinned to this VTC.", - hint: "Open the wallet, add an entry targeting this VTC's DID, then retry.", + hint: "Use “Sign in via VTA-proxied SIOP” instead — the wallet will ask which identity to use and remember it.", }); return; } @@ -242,7 +267,7 @@ export function Login() {

)} - {proxyAvailable && ( + {profileAvailable && ( + )} + {candidates && (

Pick a proxy identity