ProcAuth

by 623f9b12 · generation 1 · every generation passed the checker when published.

A shared browser-driven auth surface for a family of apps on one registrable domain: the client fetches a challenge bound to a principal and an allowlisted return_to target, a verify step consumes the challenge (single-use, bounded TTL) and creates a session, logout destroys it. Checks sessions are backed by a same-owner consumed challenge, challenges back at most one session, verification happens within TTL, authenticated redirects only go to allowlisted targets, and used challenges are never r

Raw .tla Raw .cfg

ProcAuth.tla

MODULE ProcAuth

ProcAuth: the shared first-party auth surface at auth.proc.io.

Product apps on *.proc.io link to auth.proc.io (login, register, account) with a return_to back to themselves. The pages call the auth service's (AuthGravity's) raw API from the browser. All ceremony kinds (WebAuthn passkey create/get, HKDF account-key signature, silent device-key login) share one shape, modeled as a single abstract ceremony:

Issue - GET /v1/{register,login}/options hands the browser a fresh challenge bound to the requesting principal; the return_to target is accepted only if it is a *.proc.io app. Verify - POST /v1/{register,login}/verify (or /v1/key/...): the server accepts a challenge only if it is fresh (never used) and no older than TTL, consumes it, creates a session, and the browser is sent back to the challenge's return_to. Logout - POST /v1/logout destroys the session.

The session_id cookie lives on the proc.io registrable domain, so one sign-in is valid across every *.proc.io app. The trust model is unchanged: the auth service issues and validates sessions; each app's API forwards the cookie to /v1/whoami, an access gate that is exactly "session active", so apps need no ceremony state of their own. CORS/cross-subdomain transport is not state and is out of scope.

Safety-only, finite model. The real 5-minute challenge TTL is abstracted to TTL clock ticks against a bounded clock. Challenge identities are ordered slots allocated lowest-free-slot first (symmetry breaking). NoChal (= 0) marks "no session".

EXTENDS Naturals, FiniteSets
CONSTANTS
Users, set of principals (model values)
Targets, return_to targets requested by browsers (model values)
AllowedTargets, subset of Targets: the *.proc.io apps
NumChallenges, number of challenge identity slots
MaxClock, clock bound
TTL, challenge lifetime in ticks (abstracts 5 minutes)
NULL model value: "no owner" / "no target"
ASSUME NumChallengesNatNumChallenges ≥ 1
ASSUME MaxClockNatTTLNatTTL < MaxClock
ASSUME AllowedTargetsTargets
Chals ≜ 1..NumChallenges
NoChal ≜ 0
VARIABLES
clock, 0..MaxClock
chalOwner, [Chals -> Users \cup {NULL}] principal it was issued to
chalReturn, [Chals -> Targets \cup {NULL}] validated return_to
chalIssued, [Chals -> 0..MaxClock] issue time
chalVerifiedAt, [Chals -> 0..MaxClock] consume time (used only)
chalState, [Chals -> {"none","fresh","used"}]
sessionChal, [Users -> Chals \cup {NoChal}] challenge backing the

user's active session; NoChal = logged out

redirects set of targets the browser was sent back to with a

fresh session

vars ≜ ⟨clock, chalOwner, chalReturn, chalIssued, chalVerifiedAt,
chalState, sessionChal, redirects
Init
clock = 0
chalOwner = [cChalsNULL]
chalReturn = [cChalsNULL]
chalIssued = [cChals ↦ 0]
chalVerifiedAt = [cChals ↦ 0]
chalState = [cChals"none"]
sessionChal = [uUsersNoChal]
redirects = {}
Tick
clock < MaxClock
clock = clock + 1
UNCHANGEDchalOwner, chalReturn, chalIssued, chalVerifiedAt,
chalState, sessionChal, redirects

Browser arrives with any requested return_to; the ceremony starts only when the target is an allowed *.proc.io app. The server mints a challenge bound to u. Lowest-free-slot allocation breaks challenge-labeling symmetry.

Issue(u, c, t) ≜
tAllowedTargets
chalState[c] = "none"
∧ ∀ x ∈ 1..(c - 1) : chalState[x] ≠ "none"
chalOwner = [chalOwner EXCEPT ![c] = u]
chalReturn = [chalReturn EXCEPT ![c] = t]
chalIssued = [chalIssued EXCEPT ![c] = clock]
chalState = [chalState EXCEPT ![c] = "fresh"]
UNCHANGEDclock, chalVerifiedAt, sessionChal, redirects

Server-side verify: accepts only a fresh, same-owner, unexpired challenge; consumes it (single use), creates the session, and sends the browser back to the challenge's validated return_to. A new login replaces any existing session cookie for that user.

Verify(u, c) ≜
chalState[c] = "fresh"
chalOwner[c] = u
clock - chalIssued[c] ≤ TTL
chalState = [chalState EXCEPT ![c] = "used"]
chalVerifiedAt = [chalVerifiedAt EXCEPT ![c] = clock]
sessionChal = [sessionChal EXCEPT ![u] = c]
redirects = redirects ∪ {chalReturn[c]}
UNCHANGEDclock, chalOwner, chalReturn, chalIssued

POST /v1/logout destroys the session server-side.

Logout(u) ≜
sessionChal[u] ≠ NoChal
sessionChal = [sessionChal EXCEPT ![u] = NoChal]
UNCHANGEDclock, chalOwner, chalReturn, chalIssued, chalVerifiedAt,
chalState, redirects
Next
Tick
∨ ∃ uUsers :
Logout(u)
∨ ∃ cChals :
∨ ∃ tTargets : Issue(u, c, t)
Verify(u, c)
SpecInit ∧ □[Next]vars

Invariants

TypeOK
clock ∈ 0..MaxClock
chalOwner ∈ [ChalsUsers ∪ {NULL}]
chalReturn ∈ [ChalsTargets ∪ {NULL}]
chalIssued ∈ [Chals → 0..MaxClock]
chalVerifiedAt ∈ [Chals → 0..MaxClock]
chalState ∈ [Chals → {"none", "fresh", "used"}]
sessionChal ∈ [UsersChals ∪ {NoChal}]
redirectsTargets

1. Every active session is backed by a consumed challenge that was issued to exactly that principal. No session without a completed ceremony, and never on someone else's challenge.

SessionBackedByOwnChallenge
uUsers : sessionChal[u] ≠ NoChal
chalState[sessionChal[u]] = "used"
chalOwner[sessionChal[u]] = u

2. A challenge backs at most one session (single use across users; within a user a re-login replaces the session).

ChallengeSingleUse
u1Users, u2Users :
(u1u2sessionChal[u1] ≠ NoChal) ⇒ sessionChal[u1] ≠ sessionChal[u2]

3. Every consumed challenge was verified within TTL of issuance: expired challenges never create sessions.

VerifiedWithinTTL
cChals : chalState[c] = "used"
chalVerifiedAt[c] ≥ chalIssued[c]
chalVerifiedAt[c] - chalIssued[c] ≤ TTL

4. A fresh session is only ever handed back to a *.proc.io app: no open-redirect of the authenticated browser, and every live challenge carries a validated return_to.

RedirectsOnlyToAllowed
redirectsAllowedTargets
∧ ∀ cChals : chalState[c] ≠ "none"chalReturn[c] ∈ AllowedTargets

Action properties

A consumed challenge is consumed forever: no replay of used challenges.

NoChallengeReplay
□[∀ cChals : chalState[c] = "used"chalState[c] = "used"]vars

Challenge bindings (owner, return_to, issue time) are immutable once issued.

ChallengeBindingImmutable
□[∀ cChals : chalState[c] ≠ "none"
chalOwner[c] = chalOwner[c]
chalReturn[c] = chalReturn[c]
chalIssued[c] = chalIssued[c]]vars

ProcAuth.cfg

SPECIFICATION Spec
CONSTANTS
Users = {u1, u2}
Targets = {app1, evil}
AllowedTargets = {app1}
NumChallenges = 2
MaxClock = 3
TTL = 1
NULL = NULL
INVARIANTS
TypeOK
SessionBackedByOwnChallenge
ChallengeSingleUse
VerifiedWithinTTL
RedirectsOnlyToAllowed
PROPERTIES
NoChallengeReplay
ChallengeBindingImmutable
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) Initial model: the auth ceremony moved out of individual apps into one shared first-party surface, so it is modeled as its own module: challenge issuance with return_to allowlist validation, single-use TTL-bounded verify creating a domain-wide session, logout, plus no-replay and binding-immutability action properties. Apps keep treating auth as an abstract session gate. 1412 10 2026-07-22 14:08:00 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…