Accounts and Auth Internals
Audience: Engine Developer Status: ✅ Ready
telos-account owns identity: accounts, characters, trust tiers, and the OAuth flow that turns a browser sign-in into a signed token the rest of the fleet trusts. Auth is terminal-native and OAuth-only — there are no passwords and no SSH. The design keeps the account service off the hot path: it mints a short-lived signed session assertion that the world verifies offline, so gameplay never makes a per-action auth call.
Related: RPC & Protobuf (the Account service), Trust Tier Model and First-Admin Setup (the audience-facing views), Edge & Protocol (the gate side), Sysadmin OAuth Setup (deployment).
The broker-mediated device bridge
Login is a browser device bridge, not RFC 8628 device flow and not a typed link code — the “device code” is TelosMUD’s own high-entropy handle. Only GitHub is implemented today (the migration comment lists Google/Discord, but no provider code exists for them).
sequenceDiagram
participant C as Player (telnet)
participant G as telos-gate
participant A as telos-account (broker + gRPC)
participant GH as GitHub OAuth
C->>G: connect (account-backed gate)
G->>A: StartDeviceAuth
A-->>G: device_code + login URL
G-->>C: prints the clickable URL, gate polls
C->>A: opens the login URL in a browser
A->>GH: redirect (CSRF state + PKCE verifier in a signed cookie)
GH-->>A: callback with code
A->>GH: exchange code with the PKCE verifier
A->>A: resolve-or-create account by provider uid (never email)
A-->>G: poll returns authed + account id + character list
G->>C: character select / create
The device code has a 10-minute TTL; the flow cookie is HMAC-signed; the account is keyed on the immutable (provider, provider_uid) pair, never the user-settable email. Redis backs the short-lived device sessions — without it, the broker and device auth are disabled but the gRPC API still serves.
Signed session assertions
IssueSessionAssertion mints an Ed25519-signed envelope with claims {account, character, session, exp} (and the trust tier). The gate carries it in the Play stream’s Attach frame; the world verifies it offline against the account’s public key and captures the tier only from a signature-checked claim. This is why the world never calls the account service during play — the assertion is a self-contained, cryptographically bound capability. Signing/verify keys are supplied by the operator (TELOS_ACCOUNT_SIGNING_KEY on the account service, TELOS_ACCOUNT_VERIFY_KEY on every world shard); with none configured the assertion is empty, and a discoverable world refuses to boot unless TELOS_ALLOW_INSECURE is set. The gate authenticates to the account gRPC API with a shared caller token (TELOS_ACCOUNT_CALLER_TOKEN).
Accounts, characters, and chargen
- An account comes into existence implicitly on first sign-in (default tier
player); there is no separate provisioning step. - Character names are unique (CITEXT), reserved via
ReserveName. - Chargen is content-driven (Model A):
GetChargenFlowreturns the content-defined step flow (point-buy, bundle choices) that the gate walks as prompts;CreateChargenCharactervalidates the submission with the cost curve kept server-side. The chosen output is recorded and applied by the world on first spawn — the account service only reads content and validates. See Pack Entity Reference (chargen_defs).
Trust tiers (the account side)
The trust tier is stored on the account, signed into the assertion, and applied by the world as capability flags on next login. The account service is the authority:
- Bootstrap: the
TELOS_BOOTSTRAP_ADMINconfig pin grantsadminto the first account created for that GitHub login (not email), atomically with an audit row — first-run only. Atelos-account set-tierbreak-glass CLI (host/DB access = authorization) is the recovery path. See First-Admin Setup. - Promote/demote: the in-game gate verbs call
SetAccountTier, whose authorization is enforced here — the actor’s tier is read from the authoritative store, promotion ceilings are enforced with a compare-and-set under a row lock (you can’t grant above your own authority, and concurrent changes can’t race), the path is fail-closed if the content trust ladder can’t be validated, and every change writes an audit row to the shared append-onlycharacter_audittrail in the same transaction as the tier change (atomic — a promotion is never recorded-but-unapplied or vice versa). The effect lands on the target’s next login. See Persistence & Durability → the audit trail. - The
manage_tiersvisibility bit: for visibility only,IssueSessionAssertionalso resolves amanage_tierscapability bit — the sameGrantsFlag(tier, admin)predicateSetAccountTierchecks authoritatively — and returns it alongside the assertion (resolved even on a signing-less gate; fail-safe tofalseon an unknown account, a tier-read error, or an unavailable ladder). The gate caches it per login and uses it only to decide whether to interceptpromote/demote: a non-staff actor’s attempt falls through to a worldHuh?before any parse, output, orSetAccountTiercall, so the verbs neither leak their existence nor amplify into an account-service probe. This is defense-in-depth visibility only — a stale or spoofed too-high bit still hits the authoritative service refusal (fail-closed), and a too-low bit only costs aHuh?(fail-safe).
The ladder itself is a content-defined ordinal (default player 0 / builder 20 / admin 40, with gaps for pack-inserted rungs) that both the world and the account service load, so there’s a single source of truth. The world-side application (reserved-flag denylist, rank comparison, the signed-handoff tier carry) is documented on Trust Tier Model.
The single-session lock
A character has at most one live session fleet-wide. The lock is heartbeated; a newer login anywhere in the fleet displaces the older connection (Edge & Protocol). This is what keeps a duplicated or hijacked stream from co-existing with the legitimate one.
Not implemented (schema exists, no code)
Do not document these as usable: passphrase login (an account_auth table exists but no Argon2/verify code — dropped in Phase 15), SSH pubkey login (an ssh_keys table exists but no SSH server; auth is OAuth-only), and the Google/Discord providers.