Phase A: enterprise identity foundation (SSO, MFA, device trust)
Phase A: enterprise identity foundation (SSO, MFA, device trust)
Title
Add OIDC single sign-on, TOTP multi-factor authentication, recovery codes, trusted devices, and an authoritative authentication policy
Target branch
main
Summary
Phase A of the enterprise capability roadmap. It introduces an identity layer next to the existing local-password login: OIDC providers with authorization-code + PKCE, JIT provisioning and claim-to-role mapping; TOTP enrollment with single-use challenges and rate limiting; one-time recovery codes; trusted-device cookies with per-account caps and eviction; an administrator-owned authentication policy that is stored in the database rather than in config; and six Prometheus metric families covering login, MFA, OIDC steps and background retention.
The console gains an SSO provider administration view, a second-factor panel in account security, a trusted-device list, and an MFA reset and identity-unlink action per managed account. The login page renders public SSO buttons and an MFA step without changing the existing password-login contract.
Schema moves from v13 to v14 through migrations/incremental/v0013_to_v0014/.
User impact
- Administrators can require a second factor globally (
mfa_mode=optional,required,disabled) or per account, and can turn trusted-device bypass on or off without a redeploy. - Users can enroll an authenticator app, print recovery codes, name and revoke their trusted browsers, and link or unlink an external identity.
- Existing local-password logins keep their exact request and response shape. A deployment that never configures a provider or a second factor behaves as before.
- An operator who enables SSO or MFA without injecting
TUNNELMESH_TOKEN_ENCRYPTION_KEYgets503 secret_storage_unavailableon the affected writes instead of a silent downgrade to plaintext or hash-only storage. Both Compose files and the server systemd unit now carry an injection channel for that key; seedeploy/README.md.
API, schema, and configuration impact
API. 23 new or changed paths under /api/v1/auth/*, /api/v1/sso/* and /api/v1/users/{userId}/*, all documented in docs/api/openapi.yaml. The response envelope stays {code, msg, data} and list endpoints stay cursor-paginated. Administrative mutations require Idempotency-Key; a replay by the same caller returns the stored response, a key owned by another caller answers 409 idempotency_key_conflict, and an in-flight duplicate answers 409 idempotency_in_progress. The data.error vocabulary is a closed enum of 49 stable snake_case codes.
Public OIDC routes are GET /api/v1/auth/oidc/providers (login-page buttons), GET /api/v1/auth/oidc/{providerName}/authorize and .../callback, and POST /api/v1/auth/oidc/exchange. The callback answers a 302 to a server-constant login path with either ?ticket= or ?mfa=; the redirect target is never derived from request data or from the provider row.
Schema. v13 to v14, additive only. users gains auth_source and mfa_required. Eight new tables: auth_settings, oidc_providers, user_identities, user_mfa, user_recovery_codes, user_devices, auth_challenges, auth_login_attempts, plus eight supporting indexes. All eight are added to the startup requireSchemaTables list, so auto-init: false still fails fast on a partially migrated database. There is no rolling-upgrade window for this step: internal/storage/db.go refuses to open a database whose schema_meta.version exceeds the binary’s SchemaVersion, so the header comment in both migration scripts states that rollback means restoring the pre-upgrade backup.
Configuration. A new security.auth block (22 keys) and server.trusted_proxies. Configuration seeds the auth_settings row on first start; afterwards the database row is authoritative and PUT /api/v1/auth/policy is the only way to change it. TUNNELMESH_TOKEN_ENCRYPTION_KEY, TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID and TUNNELMESH_TRACE_SIGNING_KEY remain environment-only.
Security impact
- OIDC
client_secret, TOTP shared secrets, recovery-code hashes and challenge payloads (state, nonce, PKCE verifier, login ticket) are sealed with AES-256-GCM. The master key comes only from the environment; nothing is written to disk, to the audit log or to a response body. alg=noneand every HMAC algorithm are rejected when verifying an id_token, so an attacker who learns a public key cannot forge a signature. Only RS/PS/ES/EdDSA families in the provider’s declaredidTokenAlgsare accepted.- The
issclaim must match the stored issuer (a trailing slash is normalized), andredirectUrimust be an absolute https URL inside a configured allowed base, which bounds where an assertion code can be sent even by a compromised administrator. - The provider name
providersis reserved: the public router matches that literal segment first, so a provider stored under it would be permanently unreachable. Creation and update refuse it with400 oidc_provider_invalid. - Login throttling keys on account and client address, and a wrong password answers the same
401 invalid_credentialsas an unknown username, so the endpoint cannot enumerate accounts. A disabled or soft-deleted account reached through OIDC provisioning answers403 account_disabled, which is an operator decision rather than a server defect. - Trusted-device cookies are
HttpOnly,SecureandSameSite=lax. An unrecognized or tampered cookie value is discarded and the second factor is demanded again. Revoking or renaming a device removes the bypass immediately, and the policyallowTrustedDeviceBypassrequiresdeviceTrustEnabled, so a stored bypass cannot come back to life when trust is re-enabled. - Challenges are single-use and attempt-capped; a replayed TOTP code is detected and refused. Recovery-code login decrements the stored counter.
- Audit rows record field names and identifiers only, never values that could be secrets.
- Out of scope and still not implemented: ICMP, TUN/L2 VPN, P2P NAT traversal, and arbitrary remote command execution.
Tests run
Backend:
go build ./...— cleango vet ./...— cleango test ./... -count=1— all packages passgo test -race ./...— all packages passgit diff --check— clean
Frontend:
npm test -- --run— 33 files, 288 tests passnpm run build— succeeds and mirrorsweb/distto the Go embed directory./scripts/verify-web-embed.sh—web/dist and internal/server/web_dist match
Documentation and configuration:
python3 scripts/gen_doc_index.py— plan, spec, pull-request and ADR indexes regenerated- YAML parse of
docs/api/openapi.yaml,docker-compose.cluster.ymlanddocker-compose.local.yml - Secret scan over the staged diff for private-key blocks, literal
client_secretvalues andtmrc-recovery codes — no matches
Live smoke pass against a real tunnelmesh-server binary on SQLite with auto-init: legacy login shape preserved; MFA enroll, enable and verify; challenge single-use enforcement; wrong code, unknown challenge and replayed challenge all refused; device cookie attributes and bypass; garbage cookie rejected; rename and revoke remove the bypass; recovery-code login decrements the counter 10 to 9; PUT /api/v1/auth/policy refuses a request without Idempotency-Key. Credentials and identifiers from that run were throwaway and are deliberately not recorded here.
Release steps
- Back up the database. This release contains a schema migration and the migration has no in-place downgrade path.
- Inject
TUNNELMESH_TOKEN_ENCRYPTION_KEY(andTUNNELMESH_TOKEN_ENCRYPTION_KEY_IDin a cluster) on every Server node before starting the new binary. For systemd, write/etc/tunnelmesh/server.env; for Compose, export the variable in.envor the orchestration layer. - Deploy the new binary with
auto-initenabled, or run the v13 to v14 migration explicitly first. - Verify
schema_meta.versionis 14, the eight new tables exist,/healthzis green andtunnelmesh_auth_login_totalis being scraped. - Existing logins are unaffected. Enable SSO and MFA afterwards through the console: register a provider, run its connectivity test, then set
mfa_modetooptionalbefore moving torequired. - Watch the login success and throttle rates for one release interval before requiring a second factor for everybody.
Rollback steps
Feature rollback needs no deploy: set mfa_mode to disabled, disable the OIDC providers, and turn deviceTrustEnabled off through PUT /api/v1/auth/policy. Every one of those takes effect immediately on all nodes.
Binary rollback to v13 requires restoring the pre-upgrade backup, because a v13 process refuses to open a v14 database. Do not attempt to drop the new tables in place.
Reviewer focus
internal/auth/policy.goandinternal/auth/auth_policy_service.go: the config-seeds-database-is-authoritative rule, and the interaction between the globalmfa_modeand the per-accountmfaRequiredfloor.internal/auth/oidc/: id_token verification, algorithm allowlisting, issuer normalization, JWKS caching and the body-size cap.internal/server/auth_oidc_api.go: state consumption on every terminal callback path, and the constant redirect target.internal/server/identity_services.go:authErrorStatusis the single place that turns an identity sentinel into a status and a stable code.internal/storage/identity_repository.go: device eviction orders by least-recently-seen, and the per-account device cap is enforced in storage rather than in the service.migrations/incremental/v0013_to_v0014/: additive-only statements, retry safety, and SQLite/MySQL parity.web/src/i18n/errors.ts: every stable code the server can return on these surfaces has a translation in both locales.
Integration status
Implementation, documentation and local validation are complete on codex/phase-a-sso-mfa-device-trust. Pull-request review and merge are pending.