Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 93 additions & 4 deletions vtc-service/admin-ui/src/lib/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,31 @@ interface VtaWalletProvider {
secretKind?: SecretKind;
}): Promise<VaultListWireResult>;
proxyLogin?(params: {
entryId: string;
entryId?: string;
nonce?: string;
target?: { kind: string; [k: string]: unknown };
ttlSecondsHint?: number;
}): Promise<ProxyLoginWireResult>;
/** 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<WalletProfileWireResult>;
}

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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<VtaWalletLoginResult> {
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<VtaWalletLoginResult> {
const rp = await rpDid();
const base = walletApiBase().replace(/\/+$/, "");

Expand All @@ -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
Expand All @@ -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 },
});
Expand Down Expand Up @@ -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,
};
}
47 changes: 45 additions & 2 deletions vtc-service/admin-ui/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import {
} from "@/lib/webauthn";
import {
isWalletAvailable,
isWalletProfileAvailable,
isWalletProxyAvailable,
listProxyCandidates,
loginWithWallet,
loginWithWalletProfile,
loginWithWalletProxy,
type ProxyVaultEntry,
} from "@/lib/wallet";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -242,7 +267,7 @@ export function Login() {
</p>
)}

{proxyAvailable && (
{profileAvailable && (
<button
type="button"
className="secondary"
Expand All @@ -253,6 +278,24 @@ export function Login() {
</button>
)}

{/* Secondary, and worded as the exception it is. The primary button
uses whichever identity the wallet has bound to this site; this is
for an operator holding more than one here. On a wallet too old to
resolve an identity itself it is the only proxy route, so it stays
visible in that case. */}
{proxyAvailable && (
<button
type="button"
className="link"
onClick={handleChooseIdentity}
disabled={busy}
>
{profileAvailable
? "Sign in as a different identity…"
: "Sign in via VTA-proxied SIOP (choose an entry)"}
</button>
)}

{candidates && (
<section className="card">
<h3>Pick a proxy identity</h3>
Expand Down