Add k_client browser flow demo
This commit is contained in:
parent
689587629a
commit
1d85c21d7f
|
|
@ -4,6 +4,15 @@ This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse
|
||||||
|
|
||||||
Last updated: 2026-04-25
|
Last updated: 2026-04-25
|
||||||
|
|
||||||
|
Related browser demo:
|
||||||
|
|
||||||
|
- `k_client_portal.py` can now be used in `k_client` at `http://127.0.0.1:8766` to show:
|
||||||
|
- registration
|
||||||
|
- login with card approval/denial
|
||||||
|
- protected `k_server` counter access
|
||||||
|
- logout
|
||||||
|
- explicit "k_server was not called" behavior when login is denied
|
||||||
|
|
||||||
## What This Prototype Covers
|
## What This Prototype Covers
|
||||||
|
|
||||||
- `k_proxy` creates short-lived sessions.
|
- `k_proxy` creates short-lived sessions.
|
||||||
|
|
|
||||||
11
Setup.md
11
Setup.md
|
|
@ -359,6 +359,17 @@ Session note (2026-04-25, browser target moved to k_proxy):
|
||||||
- browser traffic is now intended to go straight to `k_proxy`
|
- browser traffic is now intended to go straight to `k_proxy`
|
||||||
- the `k_client` portal remains only as a temporary bridge/compatibility layer
|
- the `k_client` portal remains only as a temporary bridge/compatibility layer
|
||||||
|
|
||||||
|
Session note (2026-04-25, k_client browser flow page):
|
||||||
|
- `k_client_portal.py` now also serves a local browser demo page again on `http://127.0.0.1:8766` inside `k_client`.
|
||||||
|
- The page is useful as an operator/demo surface:
|
||||||
|
- register user
|
||||||
|
- login with card approval or denial in `k_proxy`
|
||||||
|
- call the protected `k_server` counter
|
||||||
|
- logout
|
||||||
|
- It also makes the negative path explicit:
|
||||||
|
- if login is denied on the card, the page reports that `k_server` was not called
|
||||||
|
- Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect.
|
||||||
|
|
||||||
Session note (2026-04-25, provisional enrollment hardening):
|
Session note (2026-04-25, provisional enrollment hardening):
|
||||||
- The enrollment contract in `k_proxy` is now explicit but provisional.
|
- The enrollment contract in `k_proxy` is now explicit but provisional.
|
||||||
- Current prototype enrollment rules:
|
- Current prototype enrollment rules:
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ Status (2026-04-25):
|
||||||
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
||||||
- Current prototype flow:
|
- Current prototype flow:
|
||||||
- browser now targets `k_proxy` directly over `https://127.0.0.1:9771`
|
- browser now targets `k_proxy` directly over `https://127.0.0.1:9771`
|
||||||
- `k_client_portal.py` remains only as a temporary bridge page
|
- `k_client_portal.py` also serves a local browser flow page on `http://127.0.0.1:8766`
|
||||||
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
||||||
- Verified end-to-end through the portal:
|
- Verified end-to-end through the portal:
|
||||||
- enroll `alice`
|
- enroll `alice`
|
||||||
|
|
@ -335,7 +335,7 @@ Status (2026-04-25):
|
||||||
- direct browser target is on `k_proxy`
|
- direct browser target is on `k_proxy`
|
||||||
- login/resource flow is integrated on the direct proxy path
|
- login/resource flow is integrated on the direct proxy path
|
||||||
- enrollment now has a real client->proxy path
|
- enrollment now has a real client->proxy path
|
||||||
- the `k_client` bridge remains only for transition/compatibility
|
- the `k_client` page is now a usable demo/operator surface in addition to the direct proxy path
|
||||||
- final enrollment semantics are still provisional
|
- final enrollment semantics are still provisional
|
||||||
|
|
||||||
Status (2026-04-25, enrollment hardening):
|
Status (2026-04-25, enrollment hardening):
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ HTML = """<!doctype html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>ChromeCard Client Bridge</title>
|
<title>ChromeCard Client Flow</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f3efe8;
|
--bg: #f3efe8;
|
||||||
|
|
@ -36,6 +36,11 @@ HTML = """<!doctype html>
|
||||||
--muted: #655f56;
|
--muted: #655f56;
|
||||||
--line: #d9cfbf;
|
--line: #d9cfbf;
|
||||||
--accent: #0c6a60;
|
--accent: #0c6a60;
|
||||||
|
--accent-2: #8a5b2b;
|
||||||
|
--ok: #17653c;
|
||||||
|
--warn: #8f5b00;
|
||||||
|
--bad: #8a1f28;
|
||||||
|
--shadow: rgba(55, 41, 19, 0.08);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
|
|
@ -47,15 +52,18 @@ HTML = """<!doctype html>
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
max-width: 760px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 56px;
|
padding: 32px 20px 56px;
|
||||||
}
|
}
|
||||||
.panel {
|
.hero, .panel {
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
|
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
|
||||||
box-shadow: 0 18px 40px rgba(55, 41, 19, 0.08);
|
box-shadow: 0 18px 40px var(--shadow);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
|
|
@ -69,13 +77,40 @@ HTML = """<!doctype html>
|
||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.actions {
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.9fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.actions, .row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
a.button, button {
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
|
@ -84,8 +119,76 @@ HTML = """<!doctype html>
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
button.secondary { background: var(--accent-2); }
|
||||||
|
button.ghost {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
}
|
||||||
|
.status-card h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
}
|
||||||
|
.step-index {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 4px solid var(--accent-2);
|
||||||
|
background: rgba(138,91,43,0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
pre {
|
pre {
|
||||||
margin: 18px 0 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -94,26 +197,110 @@ HTML = """<!doctype html>
|
||||||
font-family: "SFMono-Regular", Consolas, monospace;
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<section class="panel">
|
<section class="hero">
|
||||||
<h1>ChromeCard Client Bridge</h1>
|
<h1>ChromeCard Client Flow</h1>
|
||||||
<p class="subtitle">
|
<p class="subtitle">
|
||||||
Browser traffic should now target `k_proxy` directly at `https://127.0.0.1:9771/`.
|
This page runs in `k_client` and drives the real split-VM flow:
|
||||||
This local service remains only as a temporary bridge and compatibility shim.
|
register a user, ask the card in `k_proxy` for approval, and then call
|
||||||
|
the protected counter on `k_server` only if auth succeeds.
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
|
||||||
<a class="button" href="https://127.0.0.1:9771/">Open Proxy Portal</a>
|
|
||||||
</div>
|
|
||||||
<pre id="log"></pre>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="stack">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<span class="badge">Browser: k_client</span>
|
||||||
|
<span class="badge">Card: k_proxy</span>
|
||||||
|
<span class="badge">Resource: k_server</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input id="username" value="directtest" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="registerBtn">Register User</button>
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<button id="counterBtn">Call k_server</button>
|
||||||
|
<button id="logoutBtn" class="secondary">Logout</button>
|
||||||
|
<button id="runFlowBtn" class="ghost">Run Full Flow</button>
|
||||||
|
<button id="refreshBtn" class="ghost">Refresh State</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint" id="hintBox">
|
||||||
|
Registration: press <strong>yes</strong> on the card to enroll.
|
||||||
|
Login: press <strong>yes</strong> to allow the identity check, or
|
||||||
|
<strong>no</strong> to deny it. If login is denied, this page will
|
||||||
|
show that `k_server` was not called.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">1</div>
|
||||||
|
<div>
|
||||||
|
<strong>Register user</strong><br>
|
||||||
|
Creates or refreshes the enrolled identity in `k_proxy`.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">2</div>
|
||||||
|
<div>
|
||||||
|
<strong>Authenticate with the card</strong><br>
|
||||||
|
`k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">3</div>
|
||||||
|
<div>
|
||||||
|
<strong>Call `k_server`</strong><br>
|
||||||
|
The protected counter is only reached when login created a valid session.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel status">
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Client State</h2>
|
||||||
|
<div class="status-line" id="stateUser">Enrolled user: unknown</div>
|
||||||
|
<div class="status-line" id="stateSession">Session: unknown</div>
|
||||||
|
<div class="status-line" id="stateExpires">Expires: unknown</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Flow Result</h2>
|
||||||
|
<div class="status-line" id="flowResult">No flow run yet.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2 style="margin-top:0">Event Log</h2>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const logNode = document.getElementById("log");
|
const logNode = document.getElementById("log");
|
||||||
|
const hintBox = document.getElementById("hintBox");
|
||||||
|
const flowResult = document.getElementById("flowResult");
|
||||||
|
const stateUser = document.getElementById("stateUser");
|
||||||
|
const stateSession = document.getElementById("stateSession");
|
||||||
|
const stateExpires = document.getElementById("stateExpires");
|
||||||
|
const usernameInput = document.getElementById("username");
|
||||||
|
const buttons = Array.from(document.querySelectorAll("button"));
|
||||||
|
|
||||||
function log(message, payload) {
|
function log(message, payload) {
|
||||||
const stamp = new Date().toLocaleTimeString();
|
const stamp = new Date().toLocaleTimeString();
|
||||||
let line = `[${stamp}] ${message}`;
|
let line = `[${stamp}] ${message}`;
|
||||||
|
|
@ -122,8 +309,125 @@ HTML = """<!doctype html>
|
||||||
}
|
}
|
||||||
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
||||||
}
|
}
|
||||||
log("Primary browser target moved", {open: "https://127.0.0.1:9771/"});
|
|
||||||
setTimeout(() => { window.location.href = "https://127.0.0.1:9771/"; }, 1200);
|
function setBusy(busy) {
|
||||||
|
for (const button of buttons) button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function username() {
|
||||||
|
return usernameInput.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, payload) {
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return {status: resp.status, data};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshState() {
|
||||||
|
const resp = await fetch("/api/client/state");
|
||||||
|
const data = await resp.json();
|
||||||
|
stateUser.textContent = `Enrolled user: ${data.enrolled_username || "none"}`;
|
||||||
|
stateSession.textContent = `Session active: ${data.session_active ? "yes" : "no"}`;
|
||||||
|
stateExpires.textContent = `Expires: ${data.session_expires_at || "none"}`;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser() {
|
||||||
|
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
|
||||||
|
const result = await api("/api/enroll", {username: username()});
|
||||||
|
log("Register user", result);
|
||||||
|
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
|
||||||
|
await refreshState();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUser() {
|
||||||
|
hintBox.innerHTML = "Card step: if the card shows an <strong>authentication</strong> prompt, press <strong>yes</strong> to allow login or <strong>no</strong> to deny it.";
|
||||||
|
const result = await api("/api/login", {});
|
||||||
|
log("Login", result);
|
||||||
|
await refreshState();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callCounter() {
|
||||||
|
const result = await api("/api/resource/counter", {});
|
||||||
|
log("Call k_server counter", result);
|
||||||
|
flowResult.textContent =
|
||||||
|
result.status === 200
|
||||||
|
? `k_server was reached. Counter value: ${result.data.upstream?.value}`
|
||||||
|
: "k_server was not reached successfully.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutUser() {
|
||||||
|
const result = await api("/api/logout", {});
|
||||||
|
log("Logout", result);
|
||||||
|
flowResult.textContent = result.status === 200 ? "Session cleared." : "Logout failed.";
|
||||||
|
await refreshState();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFlow() {
|
||||||
|
setBusy(true);
|
||||||
|
flowResult.textContent = "Flow running...";
|
||||||
|
try {
|
||||||
|
const login = await loginUser();
|
||||||
|
if (login.status !== 200) {
|
||||||
|
flowResult.textContent = "Login denied or failed. `k_server` was not called.";
|
||||||
|
log("Flow stopped before k_server", {
|
||||||
|
reason: "login failed",
|
||||||
|
status: login.status,
|
||||||
|
response: login.data
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const counter = await callCounter();
|
||||||
|
if (counter.status === 200) {
|
||||||
|
flowResult.textContent = `Flow succeeded. k_server returned counter ${counter.data.upstream?.value}.`;
|
||||||
|
} else {
|
||||||
|
flowResult.textContent = "Login succeeded, but the protected k_server call failed.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("registerBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await registerUser(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await loginUser();
|
||||||
|
flowResult.textContent = result.status === 200 ? "Login succeeded. You can now call k_server." : "Login denied or failed. k_server was not called.";
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("counterBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await callCounter(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await logoutUser(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("runFlowBtn").addEventListener("click", runFlow);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const state = await refreshState();
|
||||||
|
log("State refreshed", state);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshState().then((state) => {
|
||||||
|
log("Client flow page ready", state);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue