JarvisOS HomeLab status
No signal

Identity: high availability and SSO

5 capabilities · 6 failure scenarios · design designed

Identity 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.

Survives, by design 16/30 capability × scenario, no human needed
Survives today 3/30 counting only what is running
Auth instances running 1/3 2 failure domains declared
Revocation worst case 5m 0/4 paths ever proven
Drills run 0/7 none overdue

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

Survival matrix — by design

From the user’s side of each break

Derived 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 diesVIN (the public edge) is goneHome1 is goneHome internet is downGoogle OIDC is downThe 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 running

The 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 diesVIN (the public edge) is goneHome1 is goneHome internet is downGoogle OIDC is downThe 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 costs

Split 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 today

Decide, 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.
NeedsInLosing it costsWhy
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.
By scenario lose-auth-instance: Auto failoverlose-vin: Manual promotionlose-home1: Servinglose-uplink: Servingidp-outage: Servinglose-session-store: Serving

Session refresh estate-critical

4/6 scenarios survived by design · 1/6 today

Exchange 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
NeedsInLosing it costsWhy
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.
By scenario lose-auth-instance: Auto failoverlose-vin: Manual promotionlose-home1: Servinglose-uplink: Servingidp-outage: Servinglose-session-store: Lost

Interactive login estate-critical

3/6 scenarios survived by design · 0/6 today

Authenticate 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
NeedsInLosing it costsWhy
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.
By scenario lose-auth-instance: Auto failoverlose-vin: Manual promotionlose-home1: Servinglose-uplink: Servingidp-outage: Lostlose-session-store: Lost

Logout and revocation estate-critical

4/6 scenarios survived by design · 1/6 today

Make 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
NeedsInLosing it costsWhy
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.
By scenario lose-auth-instance: Auto failoverlose-vin: Manual promotionlose-home1: Servinglose-uplink: Servingidp-outage: Servinglose-session-store: Lost

Operator access with auth absent fleet-fatal break-glass

0/6 scenarios survived by design · 0/6 today

Reach 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.

By scenario lose-auth-instance: Break-glasslose-vin: Break-glasslose-home1: Break-glasslose-uplink: Break-glassidp-outage: Break-glasslose-session-store: Break-glass

Where identity actually runs

InstanceNodeDomain RoleStateServes Holds keysHealth
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
StoreRolePlacement StateWhen unavailableUsed 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 vendor

Google (OIDC)

oidc · used by login · never called on the verification path
No signal

New 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 value

session-signing

ed25519 · signs session, refresh

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
  1. 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.
  2. Distribute the new public half to every verifier and confirm each one lists both key ids before anything is signed with the new one.
  3. Switch issuing to the new key id. Verifiers now accept both.
  4. 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.
  5. Remove the old public half from every verifier, then destroy the old private half.
  6. 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

shared-secret · signs nothing

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
  1. 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.
  2. 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.
  3. Delete the old secret in the console only after every instance has been restarted and proven.
  4. 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
EventScopeDesigned worst case TodayProven
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.

CheckKindDepends on EffectDetects inState
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 hypothesis

Kill one auth instance under load

every 90 days · homelab-operator · never run

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.

  1. Confirm both instances are in the pool and both answer /healthz.
  2. 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.
  3. docker kill the container serving auth-vin-a.
  4. Record the number of failed requests in the loop and the wall-clock time until failures stop.
  5. 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

every 180 days · homelab-operator · never run

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.

  1. With a live session in a browser, stop the session store.
  2. Reload a published route repeatedly for the length of one session TTL.
  3. Attempt a fresh login from a private window.
  4. Restart the store and confirm both paths recover without a restart of the auth instances.

Simulate a Google outage

every 180 days · homelab-operator · never run

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.

  1. 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.
  2. Confirm existing sessions keep verifying and refreshes keep succeeding.
  3. Confirm a fresh login fails with a message that names the upstream rather than a generic 500.
  4. Confirm no instance leaves the pool.
  5. 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

every 90 days · homelab-operator · never run

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.

  1. From VIN, confirm which published routes still authenticate while home1 is powered off.
  2. Repeat with home1 healthy but the mesh path to it blocked, which is the uplink case and behaves differently.
  3. Record whether verification, refresh and login each survive, and for how long.
  4. 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

every 90 days · homelab-operator · never run

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.

  1. From the home side, with VIN treated as gone, reach home1 over SSH using the operator key only.
  2. Start or reach the status console on 127.0.0.1 through that session and confirm it renders with no auth service running anywhere.
  3. Confirm no step of this required a password manager, a browser, Google, or a page served through Caddy.
  4. 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

every 365 days · homelab-operator · never run

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.

  1. Perform a full routine rotation following the declared procedure, holding a live session open throughout.
  2. Confirm the held session survives the entire rotation, including the removal of the old public half after the overlap.
  3. 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.
  4. 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

every 90 days · homelab-operator · never run

Pass looks like: Every revocation bites inside 60 seconds, and the fail-static window is bounded and observable rather than indefinite.

  1. Hold a live session in one browser and log out from another.
  2. Poll a published route with the first session and record the time until it stops being served.
  3. Repeat with the denylist store deliberately unavailable, and confirm the stale-tolerance bound behaves as declared: served for up to 60s, privileged routes refused.
  4. Repeat for an administrator sign-out of another subject.

Until this passes, every revocation number in this register is an intention.

Notes

How to read this page

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.