Identity: high availability and SSO
5 capabilities · 6 failure scenarios · design designedIdentity availability for the estate: what still authenticates when a node, a site, the home uplink, the session store or Google goes away. Two postures are declared side by side — what runs today, and what this design says should run — because the whole argument for changing anything here lives in the difference between them. Today one container on one node decides whether every published request is allowed, and it answers that question by reading a Redis on the other side of a domestic broadband line. Nothing in this file has been deployed; it is a design, its gaps are real, and no drill in it has ever been run.
This page authenticates nobody, holds no credential and asks no identity provider anything. It is served on loopback over SSH so that it still answers when the SSO it describes is the thing that has failed.
The three rules hold
Nothing on the request path calls the identity provider, no pool check depends on anything outside its own instance, and the operator has a way in with the auth service entirely absent.
Open gaps
True statements about the estate, not broken configuration-
Open gap: A fleet-fatal capability is served by exactly one instance. verify"Session verification" is fleet-fatal and is served today by exactly one instance (auth-vin-a). Its loss is a total outage of every published route; 2 more are declared and not running.
-
Open gap: A per-request capability needs a store in another failure domain; the request path spans a link it cannot survive. verify:sessions-home1"Session verification" runs on every request and reads Sessions and refresh records (Redis, home1) on home1, which is in a different failure domain from auth-vin-a, auth-vin-b. Every published request therefore depends on that link being up. This is the estate as it stands today.
-
Open gap: A key has never been rotated, so the rotation procedure is unproven. session-signingsession-signing has never been rotated, so the procedure in this register has never been executed.
-
Open gap: A key has never been rotated, so the rotation procedure is unproven. oauth-clientoauth-client has never been rotated, so the procedure in this register has never been executed.
-
Open gap: A revocation path has never been proven by a drill. user-logout"user-logout" is declared but has never been proven: nobody has watched a revoked session stop working.
-
Open gap: A revocation path has never been proven by a drill. admin-sign-out-user"admin-sign-out-user" is declared but has never been proven: nobody has watched a revoked session stop working.
-
Open gap: A revocation path has never been proven by a drill. global-sign-out"global-sign-out" is declared but has never been proven: nobody has watched a revoked session stop working.
-
Open gap: A revocation path has never been proven by a drill. oauth-client-compromise"oauth-client-compromise" is declared but has never been proven: nobody has watched a revoked session stop working.
-
Open gap: An edge routes to a pool with no check that removes a dead instance from it. vin-caddyvin-caddy is serving today and its pool check "auth-liveness" is declared but not deployed. Until it is, a wedged instance stays in rotation for as long as it takes a human to notice.
-
Open gap: A declared drill has never been run. drill-instance-loss"Kill one auth instance under load" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-session-store-loss"Take the session store away" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-idp-outage"Simulate a Google outage" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-site-loss"Lose the home site, and then the home uplink" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-edge-loss"Lose VIN and prove break-glass" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-key-rotation"Rotate the session signing key, with and without overlap" has never been run, so everything this register claims about that scenario is a hypothesis.
-
Open gap: A declared drill has never been run. drill-revocation"Prove a revoked session actually stops working" has never been run, so everything this register claims about that scenario is a hypothesis.
Survival matrix — by design
From the user’s side of each breakDerived from the topology below, never declared. The break-glass row never counts as survival: it is a way back in, not a service that is still serving.
| Identity capability | One auth instance dies | VIN (the public edge) is gone | Home1 is gone | Home internet is down | Google OIDC is down | The session store is gone |
|---|---|---|---|---|---|---|
| Session verification | Auto failover 26s | Manual promotion | Serving | Serving | Serving | Serving |
| Session refresh | Auto failover 26s | Manual promotion | Serving | Serving | Serving | Lost |
| Interactive login | Auto failover 26s | Manual promotion | Serving | Serving | Lost | Lost |
| Logout and revocation | Auto failover 26s | Manual promotion | Serving | Serving | Serving | Lost |
| Operator access with auth absent | Break-glass | Break-glass | Break-glass | Break-glass | Break-glass | Break-glass |
Survival matrix — today
Counting only instances, stores and checks that are actually runningThe difference between these two tables is the whole argument for changing anything. Today one container on one node answers “is this request allowed?”, and it answers it by reading a store at the other end of a domestic broadband line.
| Identity capability | One auth instance dies | VIN (the public edge) is gone | Home1 is gone | Home internet is down | Google OIDC is down | The session store is gone |
|---|---|---|---|---|---|---|
| Session verification | Lost | Lost | Lost | Lost | Serving | Lost |
| Session refresh | Lost | Lost | Lost | Lost | Serving | Lost |
| Interactive login | Lost | Lost | Lost | Lost | Lost | Lost |
| Logout and revocation | Lost | Lost | Lost | Lost | Serving | Lost |
| Operator access with auth absent | Break-glass | Break-glass | Break-glass | Break-glass | Break-glass | Break-glass |
The capabilities
What each one needs, and what losing it costsSplit deliberately: verification runs on every request and must survive everything, login runs once and is allowed to depend on a vendor. Merging them is how an estate ends up down because Google is.
Session verification fleet-fatal hot path
5/6 scenarios survived by design · 1/6 todayDecide, on every single published request, whether the caller already holds a valid session. This is the capability whose loss is indistinguishable from the estate being down.
- Served by
- auth-vin-a, auth-vin-b, auth-home1
- Failure domains
- fd-vps, fd-home-rack
- Judged from
- a user arriving at the public edge
- Degraded means
- Revocations newer than the last denylist snapshot are not yet honoured; routes marked privileged fail closed rather than serve from a stale list.
| Needs | In | Losing it costs | Why |
|---|---|---|---|
store:session |
current only | the whole capability | Today the session is an opaque cookie and every request is a lookup in Redis on home1. That makes home broadband a dependency of every public request in the estate. |
key:session-signing |
target only | the whole capability | In the target design the session is self-contained and signed; verification is a signature check against a public key already on the node. |
store:denylist |
target only | a declared degradation | The denylist is what stops a self-contained session outliving its revocation. It is allowed to be stale, within a stated bound, because failing every request when the denylist is unreachable would hand back exactly the availability the design just bought. |
- The move from a store lookup to a signature check is the single change in this register that alters the availability of the whole estate. Everything else here is redundancy around it.
Session refresh estate-critical
4/6 scenarios survived by design · 1/6 todayExchange a long-lived refresh record for a new short-lived session, so that a 10-minute session does not mean a login every 10 minutes.
- Served by
- auth-vin-a, auth-vin-b, auth-home1
- Failure domains
- fd-vps, fd-home-rack
- Judged from
- a user arriving at the public edge
| Needs | In | Losing it costs | Why |
|---|---|---|---|
store:session |
both postures | the whole capability | The refresh record is server-side state by design: it is the thing an administrator deletes to actually sign someone out. |
key:session-signing |
target only | the whole capability | A refresh issues a new session, which means signing one. |
- Refresh failing is survivable in a way verification failing is not: everyone keeps working for up to the session TTL, and the outage surfaces as a wave of logins rather than a wall of 502s.
Interactive login estate-critical
3/6 scenarios survived by design · 0/6 todayAuthenticate a human who does not yet hold a session, against the upstream identity provider.
- Served by
- auth-vin-a, auth-vin-b, auth-home1
- Failure domains
- fd-vps, fd-home-rack
- Judged from
- a user arriving at the public edge
| Needs | In | Losing it costs | Why |
|---|---|---|---|
upstream:google-oidc |
both postures | the whole capability | This is the one capability that is allowed to depend on a vendor, and the reason the others must not. |
key:oauth-client |
both postures | the whole capability | The client credential is what authenticates this estate to Google during the code exchange. |
store:session |
both postures | the whole capability | A completed login has to persist a refresh record somewhere. |
store:oauth-state |
target only | the whole capability | State and PKCE verifiers are per-login and short-lived. They are node-local on purpose: an in-flight login is worth less than the availability of the node it started on. |
- A Google outage stops new logins and touches nothing else. That is the correct blast radius, and it only stays correct while nothing on the hot path calls Google.
Logout and revocation estate-critical
4/6 scenarios survived by design · 1/6 todayMake a session stop working: an ordinary logout, an administrator signing a user out everywhere, or an emergency sign-out of everyone.
- Served by
- auth-vin-a, auth-vin-b, auth-home1
- Failure domains
- fd-vps, fd-home-rack
- Judged from
- a user arriving at the public edge
| Needs | In | Losing it costs | Why |
|---|---|---|---|
store:session |
both postures | the whole capability | Deleting the refresh record is what stops a session being renewed. |
store:denylist |
target only | the whole capability | Deleting the refresh record does not invalidate a self-contained session that has already been issued. The denylist does, and if it cannot be written the revocation has not happened. |
- Revocation is the price of stateless verification, and this is the register that has to state the price rather than imply it. Today a logout is immediate because every request is a lookup; in the target design it is bounded by the denylist snapshot interval instead.
Operator access with auth absent fleet-fatal break-glass
0/6 scenarios survived by design · 0/6 todayReach a node, read the state of the estate and restart things when the SSO that gates everything else is the thing that is broken.
- Served by
- nothing declared
- Failure domains
- —
- Judged from
- the operator’s side of the break
Requires nothing from the auth service. That is the point of it.
- SSH with a key held by the operator, plus this console on 127.0.0.1:8787 reached through that SSH session. Neither path touches jarvisos-auth, Google, Redis or the signing key, which is the entire requirement.
- This is why the console refuses to bind anything but loopback and why nothing in it authenticates against the SSO: an admin page behind the auth it exists to repair is a locked door with the key inside.
Where identity actually runs
| Instance | Node | Domain | Role | State | Serves | Holds keys | Health |
|---|---|---|---|---|---|---|---|
|
vin-auth (container 0e63eb495f64)
The estate as it stands: one container, uid 10001, reached by Caddy over the docker bridge. Not public, and not redundant.
|
vin | fd-vps | active | running | verify, refresh, login, revoke | session-signing, oauth-client | No signal |
|
vin-auth (second container)
A second container on the same node, in the same forward-auth pool. It buys the failure that actually happens — a wedged or restarting process — and buys nothing at all against losing the VPS. Adding it to the pool is a Caddyfile change, and the Caddyfile is managed configuration: JarvisOS does not edit it.
|
vin | fd-vps | active | planned | verify, refresh, login, revoke | session-signing, oauth-client | — |
|
vin-auth (home1 standby)
The only instance in a second failure domain. It cannot serve the public edge while VIN is the only public edge — it exists to gate tailnet-internal routes today, and to become the auth for the second edge when one exists. Its value in the matrix is exactly as large as the second edge is real.
|
home1 | fd-home-rack | standby | planned | verify, refresh, login, revoke | session-signing, oauth-client | — |
Session state, and what happens when it is gone
Addressed by role: more than one store may fill one| Store | Role | Placement | State | When unavailable | Used by |
|---|---|---|---|---|---|
|
Sessions and refresh records (Redis, home1)
data register:
jarvisos-sessions |
session |
home1 · remote — another node, and a link that can fail | running | fail closed — deny while it is unavailable | verify, refresh, login, revoke |
| Sessions and refresh records (Redis, VIN) | session |
vin · node-local — same node, separate process | planned | fail closed — deny while it is unavailable | refresh, login, revoke |
| Revocation denylist (VIN, in-process with a node-local backing set) | denylist |
vin · in-process — shares the verifier’s fate exactly | planned | fail static — serve the last snapshot, within a bound · bounded at 1m | verify, revoke |
| OAuth state and PKCE verifiers (VIN, node-local) | oauth-state |
vin · node-local — same node, separate process | planned | fail closed — deny while it is unavailable | login |
| OAuth state and PKCE verifiers (home1, node-local) | oauth-state |
home1 · node-local — same node, separate process | planned | fail closed — deny while it is unavailable | login |
Upstream identity providers
The one dependency allowed to be a vendorNew interactive logins fail for as long as the outage lasts. Every session already issued keeps working, and every refresh keeps working, because neither asks Google anything.
Credential: GOOGLE_CLIENT_SECRET in /etc/jarvisos/config.env on VIN — referred to by name and location only, never read into this register or any log line.
Fallback (planned): One local credential for one operator account, verified on the node with an argon2id hash and a second factor, usable only from the tailnet and only for routes marked break-glass. It exists so that a multi-hour Google outage does not also mean nobody can log in to fix anything. It is not implemented, and until it is, an IdP outage means no new logins at all.
Keys
Referred to by name and location; never by valuesession-signing
Signs sessions and refresh records; every verifier checks that signature
- Where
- Private half at /etc/jarvisos/auth/keys/session-signing.<kid>.pem on each issuing node, mode 0600, owned by the auth service user. Public half published to each verifier as a JWKS file at /etc/jarvisos/auth/jwks.json. Excluded from every replicated storage tier by the rule in config/storage.json — live secrets do not go anywhere that replicates.
- Held by
- auth-vin-a (running), auth-vin-b (planned), auth-home1 (planned)
- Rotation
- every 90 days, 1d overlap · never performed
- Generate the new keypair on the node that will issue with it. Never copy a private half between nodes over anything but the mesh, and never through a paste buffer.
- Distribute the new public half to every verifier and confirm each one lists both key ids before anything is signed with the new one.
- Switch issuing to the new key id. Verifiers now accept both.
- Wait the overlap window — 24h, which is longer than the 12h refresh lifetime, so nothing signed with the old key is still alive at the end of it.
- Remove the old public half from every verifier, then destroy the old private half.
- Skipping the overlap is the emergency procedure, not the routine one: it is how everyone is signed out at once, and it is listed as such under revocation.
oauth-client
Authenticates this estate to Google when exchanging an authorization code
- Where
- GOOGLE_CLIENT_SECRET in /etc/jarvisos/config.env on VIN, mode 0600, root-owned. Its value appears in no register, no page, no log and no report.
- Held by
- auth-vin-a (running), auth-vin-b (planned), auth-home1 (planned)
- Rotation
- every 365 days, immediate overlap · never performed
- Create a second client secret in the Google Cloud console. Google permits two to be active at once; that is the overlap window and it is the only reason this is not an outage.
- Write the new value into /etc/jarvisos/config.env on each node holding it, then restart each auth instance one at a time and confirm a real login completes between restarts.
- Delete the old secret in the console only after every instance has been restarted and proven.
- Existing sessions are unaffected throughout: nothing on the verification path uses this credential.
Logout, and what it actually means
Worst case in seconds, for today and for the design| Event | Scope | Designed worst case | Today | Proven |
|---|---|---|---|---|
| A user logs out Delete the refresh record and write the session id to the denylist. Verifiers refresh their copy every 15s, so the session stops being honoured within one refresh interval; the bound is 60s, the same bound the denylist is allowed to be stale for. |
session | 1m |
immediate
Delete the server-side session in Redis. The next request finds nothing and is rejected.
|
never |
| An administrator signs a user out everywhere Delete every refresh record for the subject and add each live session id to the denylist. Same 60s bound. |
user | 1m |
immediate
Delete every session key for the subject in Redis.
|
never |
| Emergency sign-out of everyone Rotate the session signing key with no overlap window. Every session and refresh record becomes unverifiable the moment each verifier has the new JWKS; the bound is how long distribution to all verifiers takes. |
global | 5m |
immediate
Flush the Redis session store. Everyone is signed out at once; the job queue in the same instance is collateral damage, which is one more reason to split them.
|
never |
| The OAuth client secret is exposed Add a second client secret in the Google console, roll it into /etc/jarvisos/config.env on every holder, restart each instance in turn, then delete the old secret. New logins are the only thing at risk during the roll. |
client | 30m |
15m
Same procedure; one holder, so one restart.
|
never |
Health checks, and which one is allowed to change routing
Exactly one kind may remove an instance from the pool, and it may only ask a question about that instance. Every check that touches the session store or the identity provider alerts and nothing else — otherwise one shared dependency deregisters every instance at once.
| Check | Kind | Depends on | Effect | Detects in | State |
|---|---|---|---|---|---|
| Auth instance liveness In-process and dependency-free on purpose: this is the only check allowed to remove an instance from the pool, so it must answer a question about that instance and nothing else. Worst-case detection is 26s, inside the 30s failover budget. |
liveness | nothing outside the instance | removes the instance from the pool | 26s | planned |
| Auth instance readiness Checks whether this instance could complete a login right now, which means touching the session store. It alerts and never deregisters: wiring this to the pool would let one store blip remove every instance at once and convert a degraded login path into a total outage. |
readiness | session | alerts a human, changes no routing | 2m | planned |
| End-to-end synthetic login The only check that proves the whole path works rather than that each part answers. It runs from the edge with a dedicated account and alerts only — it depends on a vendor, and a vendor must never be able to deregister anything here. |
synthetic-login | session, google-oidc | alerts a human, changes no routing | 16m | planned |
| Upstream identity provider reachability Exists so that 'nobody can log in' is distinguishable from 'nobody can reach anything' without a human having to work it out at 3am. That distinction is already the first line of the auth runbook. |
dependency | google-oidc | alerts a human, changes no routing | 21m | planned |
Recovery drills
An undrilled failover is a hypothesisKill one auth instance under load
Pass looks like: Fewer than 30 seconds of partial failures, and no request fails after the pool check has deregistered the dead instance. Sessions are not lost: nobody is logged out by this.
- Confirm both instances are in the pool and both answer /healthz.
- Open a published route in a browser holding a live session, and leave a one-request-per-second loop running against it from the tailnet.
- docker kill the container serving auth-vin-a.
- Record the number of failed requests in the loop and the wall-clock time until failures stop.
- Restart the container and confirm it re-enters the pool.
The failure this drill covers is the one that actually happens. It is also the only one the current single-instance deployment cannot pass at all.
Take the session store away
Pass looks like: In the target design: verification keeps working until the session expires, login fails immediately with a clear error, and no auth instance is deregistered by the readiness check. Today: everything fails at once, which is the result this drill exists to make undeniable.
- With a live session in a browser, stop the session store.
- Reload a published route repeatedly for the length of one session TTL.
- Attempt a fresh login from a private window.
- Restart the store and confirm both paths recover without a restart of the auth instances.
Simulate a Google outage
Pass looks like: Verification and refresh unaffected; login fails cleanly; nothing is deregistered. Any deviation means something on the hot path is calling the vendor.
- Block egress to the provider's endpoints from the auth instances only. Do not touch the firewall on any other path, and revert with a scheduled command in case the session is lost.
- Confirm existing sessions keep verifying and refreshes keep succeeding.
- Confirm a fresh login fails with a message that names the upstream rather than a generic 500.
- Confirm no instance leaves the pool.
- Unblock and confirm login recovers with no restart.
This is the drill that proves the first rule in src/auth-ha.js rather than asserting it.
Lose the home site, and then the home uplink
Pass looks like: In the target design: verification and login unaffected, because nothing on either path is at home. Today: everything published stops authenticating the moment home1 is unreachable.
- From VIN, confirm which published routes still authenticate while home1 is powered off.
- Repeat with home1 healthy but the mesh path to it blocked, which is the uplink case and behaves differently.
- Record whether verification, refresh and login each survive, and for how long.
- Bring the site back and confirm nothing needed a restart.
The two halves are one drill because the difference between them — a dead node versus a live but unreachable one — is exactly where a design that assumed 'down' silently fails.
Lose VIN and prove break-glass
Pass looks like: The operator has a working shell and a working status page within 15 minutes, with the SSO entirely absent. Nothing published is expected to work — VIN is the only public edge.
- From the home side, with VIN treated as gone, reach home1 over SSH using the operator key only.
- Start or reach the status console on 127.0.0.1 through that session and confirm it renders with no auth service running anywhere.
- Confirm no step of this required a password manager, a browser, Google, or a page served through Caddy.
- Record what remained unreachable, honestly: every published route is one of them.
This drill is about the operator's access, not the estate's availability. Conflating the two is how an estate ends up with a beautiful recovery plan nobody can open.
Rotate the session signing key, with and without overlap
Pass looks like: A routine rotation logs nobody out. An emergency rotation logs everybody out within five minutes and leaves the system able to issue new sessions immediately.
- Perform a full routine rotation following the declared procedure, holding a live session open throughout.
- Confirm the held session survives the entire rotation, including the removal of the old public half after the overlap.
- Then perform the emergency variant on a test key id: skip the overlap and confirm every session dies at once and a fresh login works immediately afterwards.
- Record the wall-clock time of the second variant — that number is the global sign-out worst case, and it should replace the declared one.
Prove a revoked session actually stops working
Pass looks like: Every revocation bites inside 60 seconds, and the fail-static window is bounded and observable rather than indefinite.
- Hold a live session in one browser and log out from another.
- Poll a published route with the first session and record the time until it stops being served.
- Repeat with the denylist store deliberately unavailable, and confirm the stale-tolerance bound behaves as declared: served for up to 60s, privileged routes refused.
- Repeat for an administrator sign-out of another subject.
Until this passes, every revocation number in this register is an intention.
Notes
-
Note: DNS failover is a human act, and a slow oneRepointing the public record takes at least 10m (TTL plus propagation) and a person: the zone is owned by the owner and JarvisOS does not touch DNS by rule. Nothing on the request path may assume it is faster than that.
How to read this page
- Login may fail; verification may not. An upstream identity provider decides who someone is, once. It must never be asked whether a request already carrying a valid session is allowed — that answer has to be computable on the node serving the request, with no network call to anyone.
- Two numbers define the design: a session is verifiable for 600s without a lookup, and a revocation must bite within 600s. The first buys availability, the second bounds what that availability costs when a session has to be killed. Moving either without the other is the mistake this policy exists to prevent.
- Auth availability in this estate is capped by the number of public edges, which is one. Two auth instances behind one Caddy survive a crashed container, not a lost VPS. That is a real thing to buy — a wedged process is the failure that actually happens — but it must be called by its name.
- Every published route is gated here, and this console is deliberately not: it binds loopback and is reached over SSH. An auth outage is exactly when an operator needs a status page, and a status page behind the thing that is broken is a status page nobody can open.
Source: config/auth-ha.json · machine-readable at /api/v1/auth ·
narrative in docs/AUTH-HA.md · the control-plane view of the same estate is at
/control-plane.