feat: add PasswordAuth blueprint illustration

The primary-auth feature RememberMe composes onto. Transcribed from the
ontology password-auth README + .als: the full AuthState lifecycle
(Idle / Submitting / Authenticated / Refreshing / LockedOut / Error), its
8 functions, and a sign-in specimen shown across those states. Adds the
auth transition kinds and wraps the state-machine legend to a second row
so this richer machine's kinds fit without clipping.
This commit is contained in:
Julien Calixte
2026-07-02 23:36:08 +02:00
parent 5dff87a90d
commit 4d2db834ac
5 changed files with 837 additions and 2 deletions

View File

@@ -0,0 +1,348 @@
// The PasswordAuth blueprint, as data. Transcribed by hand from the ontology
// `password-auth` README + password-auth.als. A *feature* that extends Form: the
// user submits a credential, the backend verifies it, and a bounded session is
// established — with uniform failures (no username enumeration), lockout, and
// silent refresh. It is the host that RememberMe / MFA / Captcha compose onto.
import type { Blueprint, StateMachine } from "./blueprint"
// The full AuthState lifecycle. Idle → Submitting fans out to Authenticated /
// Error / LockedOut; Authenticated ⇄ Refreshing; and reset() / unlock() / signOut()
// all return to Idle. Node coordinates are hand-placed for a clean layout.
const PASSWORD_AUTH_MACHINE: StateMachine = {
initId: "idle",
caption: "Session lifecycle",
field: "state : AuthState",
nodes: [
{ id: "idle", x: 30, y: 140, w: 90, h: 44, label: "Idle" },
{ id: "submitting", x: 190, y: 140, w: 120, h: 44, label: "Submitting" },
{ id: "authenticated", x: 400, y: 140, w: 140, h: 44, label: "Authenticated" },
{ id: "refreshing", x: 630, y: 44, w: 130, h: 44, label: "Refreshing" },
{ id: "lockedout", x: 175, y: 252, w: 120, h: 44, label: "LockedOut" },
{ id: "error", x: 395, y: 252, w: 100, h: 44, label: "Error" },
],
edges: [
{ from: "idle", to: "submitting", label: "signIn(c)", kind: "submit", off: -34 },
{ from: "submitting", to: "authenticated", label: "succeed(id)", kind: "succeed", off: -34 },
{ from: "submitting", to: "lockedout", label: "lockOut()", kind: "lock", off: -20 },
{ from: "submitting", to: "error", label: "fail()", kind: "fail", off: -24 },
{ from: "authenticated", to: "refreshing", label: "refresh()", kind: "refresh", off: -26 },
{ from: "refreshing", to: "authenticated", label: "refreshed(id)", kind: "succeed", off: -26 },
{ from: "refreshing", to: "error", label: "refreshFailed()", kind: "fail", off: -30 },
{ from: "authenticated", to: "idle", label: "signOut()", kind: "signout", off: 120 },
{ from: "error", to: "idle", label: "reset()", kind: "reset", off: -40 },
{ from: "lockedout", to: "idle", label: "unlock()", kind: "unlock", off: 30 },
],
}
export const passwordAuth: Blueprint = {
slug: "password-auth",
name: "PasswordAuth",
tagline:
"Submit a credential, verify it against the backend, and hold a bounded session — with uniform failures, lockout, and silent refresh.",
role: "feature",
extendsName: "Form",
sig: {
header: "PasswordAuth ◁ Form",
fields: [
{ name: "state", type: "AuthState", note: "lifecycle position" },
{ name: "identity", type: "lone Identity", note: "present iff Authenticated or Refreshing" },
{
name: "pendingCredential",
type: "lone Credential",
note: "present only during Submitting",
},
],
types: [
"AuthState = Idle | Submitting | Authenticated | Refreshing | LockedOut | Error",
"Identity { subject : Subject, realm : Realm } -- subject unique per realm",
"Credential { username : Username, secret : Secret } -- 8 ≤ len(secret) ≤ 128",
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing}",
"pendingCredential ≠ ⊥ ⟺ state = Submitting",
],
},
functions: [
{
id: "capture",
name: "Capture",
fig: "01",
caps: ["PasswordAuth", "Form"],
verb: "Collect username and secret from the form; mask the secret and mount both fields together so the OS credential manager can pair them.",
state: [
["username", "Username"],
["secret", "Secret"],
],
behaviors: [],
invariants: [
"len(secret) ∈ [8, 128] — enforced before signIn(c) fires; the upper bound is surfaced to the user, never silently accepted (bcrypt truncates at 72 bytes)",
],
failures: [
"Form auto-submit — browser autofill triggers signIn without user intent; the unintended submission locks the user out faster on stale credentials",
],
perf: [
"Form responsiveness — input fields remain accessible during Submitting; the submit button is disabled to prevent double-fire",
],
notes: [
"Username and password are mounted together on the submitted form so the credential manager can pair them on save.",
],
},
{
id: "submit",
name: "Submit",
fig: "02",
caps: ["PasswordAuth"],
verb: "Transmit the credential over an authenticated channel (TLS) on a user gesture; never log or persist the secret.",
state: [
["state", "AuthState"],
["pendingCredential", "lone Credential"],
],
behaviors: [
{
sig: "signIn(c)",
pre: "state = Idle",
eff: "state ← Submitting, pendingCredential ← c",
},
],
invariants: [
"pendingCredential ≠ ⊥ ⟺ state = Submitting — the credential is held only across the verification call",
"the plaintext secret is never persisted, logged, or held past Submitting",
],
failures: [
"Plaintext leak — secret logged, persisted, or kept in app memory past Submitting; exposed to crash dumps, analytics, or an attacker with disk read",
"Form auto-submit — autofill fires signIn without a user gesture",
],
perf: [
"Verification — signIn to Authenticated or Error completes within 2 s on a stable network",
],
notes: [
"The signIn(c) submission must be triggered by a user gesture, never auto-fired on autofill completion.",
],
},
{
id: "verify",
name: "Verify",
fig: "03",
caps: ["PasswordAuth"],
verb: "Backend checks the credential against the credential store; returns an Identity or a uniform failure that never distinguishes unknown-user from wrong-password.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "succeed(id)",
pre: "state = Submitting, verification passed",
eff: "state ← Authenticated, identity ← id, pendingCredential ← ⊥",
},
{
sig: "fail()",
pre: "state = Submitting, verification failed",
eff: "state ← Error, pendingCredential ← ⊥",
},
],
invariants: [
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
"fail() does not distinguish 'wrong password' from 'unknown username' — messages stay uniform",
],
failures: [
"Username enumeration — fail() reason distinguishes 'no such user' from 'wrong password'; an attacker maps valid usernames before a targeted attack",
"Timing attack — verification time differs for valid vs invalid usernames; the attacker infers validity from response latency",
"Credential reuse — the backend accepts a known-breached password, so a password compromised elsewhere grants access here",
],
perf: [
"Constant-time response — failure responses take the same time regardless of username validity",
"Failure visibility — Error surfaces with a uniform message, never revealing whether the username exists",
],
notes: [],
},
{
id: "limit",
name: "Limit",
fig: "04",
caps: ["PasswordAuth"],
verb: "Track failed attempts per user / per IP; transition to LockedOut past the threshold.",
state: [["state", "AuthState"]],
behaviors: [
{
sig: "lockOut()",
pre: "state = Submitting, attempt threshold exceeded",
eff: "state ← LockedOut, pendingCredential ← ⊥",
},
],
invariants: [
"state = LockedOut is reached only from Submitting via lockOut() — never user-triggerable directly",
],
failures: [
"Brute force — no attempt counting / no lockOut threshold; the attacker tries unbounded password guesses",
"Lockout bypass — unlock() callable without elapsed cooldown; the rate limit is defeated and brute force resumes",
],
perf: [
"Lockout feedback — LockedOut surfaces to the user within 1 s of trigger, with the cooldown duration shown",
],
notes: [],
},
{
id: "establish",
name: "Establish",
fig: "05",
caps: ["PasswordAuth", "Identity"],
verb: "Persist the session token; expose subject and realm to the consumer.",
state: [["identity", "lone Identity"]],
behaviors: [],
invariants: [
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
"the consumer sees subject and realm; the plaintext secret never crosses the client-persistence boundary",
],
failures: [
"Replay (token theft) — the session token leaks (XSS, network capture); the attacker impersonates the user until the token expires",
],
perf: [],
notes: [
"Client persistence may store Identity.subject / Identity.realm and the composed session credential — never the plaintext secret in any form.",
],
},
{
id: "refresh",
name: "Refresh",
fig: "06",
caps: ["PasswordAuth"],
verb: "Renew the session token silently before expiry; preserve subject and realm.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "refresh()",
pre: "state = Authenticated, session token refreshable",
eff: "state ← Refreshing",
},
{
sig: "refreshed(id)",
pre: "state = Refreshing, id.subject = identity.subject, id.realm = identity.realm",
eff: "state ← Authenticated, identity ← id",
},
{
sig: "refreshFailed()",
pre: "state = Refreshing",
eff: "state ← Error, identity ← ⊥",
},
],
invariants: [
"refreshed(id) preserves both subject and realm — refresh never silently swaps user or tenant",
],
failures: [
"Subject swap on refresh — refreshed(id) accepts an id with a different subject or realm; the attacker's refresh response steals the session",
"Stale session — the session is used past expiry without refresh; API calls 401 and the user is surprise-logged-out",
],
perf: [
"Silent refresh — completes within 200 ms and before any in-flight API call observes a 401",
],
notes: [],
},
{
id: "recover",
name: "Recover",
fig: "07",
caps: ["PasswordAuth"],
verb: "Surface Error distinctly from LockedOut; expose reset() and the cooldown-driven unlock().",
state: [["state", "AuthState"]],
behaviors: [
{ sig: "reset()", pre: "state = Error", eff: "state ← Idle" },
{
sig: "unlock()",
pre: "state = LockedOut, cooldown elapsed",
eff: "state ← Idle",
},
],
invariants: [
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
],
failures: [
"Forgotten password loop — no recovery path from LockedOut or an unknown username; the user is permanently locked out with no self-service unblock",
],
perf: [],
notes: [
"Recovery from a forgotten password is a sibling blueprint (PasswordReset), not part of this contract.",
],
},
{
id: "terminate",
name: "Terminate",
fig: "08",
caps: ["PasswordAuth"],
verb: "Clear the local session and invalidate the session token at the backend.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "signOut()",
pre: "state = Authenticated",
eff: "state ← Idle, identity ← ⊥",
},
],
invariants: ["signOut() clears identity — no active session remains locally"],
failures: [
"Stale session — a logged-out session token still valid at the backend can be replayed",
],
perf: [],
notes: [
"A logout is not a credential operation — the secret never crosses the logout boundary; only the session credential is sent so the server can revoke.",
],
},
],
coreInvariants: [
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
"pendingCredential ≠ ⊥ ⟺ state = Submitting — credentials are held only across the verification call",
"the plaintext secret is never persisted, logged, or held past Submitting",
"refreshed(id) preserves both subject and realm — refresh never swaps user or tenant",
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
"len(secret) ∈ [8, 128] is enforced before signIn(c) fires",
],
composition: {
caps: [
{ id: "core", label: "PasswordAuth : Form", sub: "state, identity", core: true },
{ id: "form", label: "Form", sub: "fields, filled, invalid" },
{ id: "cred", label: "Credential", sub: "username, secret" },
{ id: "identity", label: "Identity", sub: "subject, realm" },
],
funcs: ["Capture", "Submit", "Verify", "Limit", "Establish", "Refresh", "Recover", "Terminate"],
links: [
["form", "Capture"],
["cred", "Capture"],
["cred", "Submit"],
["core", "Submit"],
["core", "Verify"],
["identity", "Verify"],
["core", "Limit"],
["core", "Establish"],
["identity", "Establish"],
["core", "Refresh"],
["identity", "Refresh"],
["core", "Recover"],
["form", "Recover"],
["core", "Terminate"],
["identity", "Terminate"],
],
},
stateMachine: PASSWORD_AUTH_MACHINE,
related: [
{ name: "Form", relation: "extends · base" },
{ name: "RememberMe", relation: "composable extension" },
{ name: "MFA", relation: "composable extension" },
{ name: "Captcha", relation: "composable extension" },
{ name: "StatelessSession", relation: "composable extension" },
{ name: "PasswordReset", relation: "sibling · recovery" },
{ name: "SSO", relation: "sibling · delegated" },
{ name: "Passkey", relation: "sibling · passwordless" },
],
}