Homelab Weekly: OIDC SSO, MFA, and Talos Cluster Reorganization

Aug 17, 2026 min read

This week was split between identity work in DurpDeploy and a fairly consequential cleanup of the GitOps repository that describes the homelab. The headline feature is OIDC single sign-on for DurpDeploy, backed by the existing identity provider. I also landed a substantial MFA implementation and its test infrastructure, then followed up with the less glamorous but necessary reliability work around Authentik, Longhorn, Traefik middleware, and the cluster directory layout.

The two streams are connected. Identity is only useful when it is deployed predictably, resource-starved components stay alive, and the ingress path is unambiguous. The result is a more coherent path from an identity provider login, through Traefik and Authentik, to the deployment console, with clearer operational boundaries around the old and current DMZ clusters.

DurpDeploy: OIDC Single Sign-On

I merged OIDC support into DurpDeploy this week. The implementation is optional and deliberately leaves local authentication in place. That matters operationally: a failure at the identity provider should not turn into an application-wide lockout, and an administrator still needs a local recovery route when external identity configuration is wrong.

OIDC is enabled only when the full configuration is present. The application takes a public application origin, issuer URL, client ID, client secret, role group names, display label, group-claim name, and the email-verification policy from DURPDEPLOY_OIDC_* environment variables. The chart has a matching oidc section, disabled by default. When enabled, oidc.publicURL and the existing Secret reference are required at template render time. The client secret is read from a Kubernetes Secret, rather than values files or a generated ConfigMap, which keeps it out of normal Helm values and repository history.

The public URL is not cosmetic configuration. The callback URI is derived exactly as DURPDEPLOY_URL + /login/oidc/callback, so the application and identity provider have a single explicit contract. The configured public URL and issuer must be HTTPS origins. The requested scopes are fixed to openid, profile, and email; I did not introduce configurable scope sprawl for a use case that does not need it. The deployed Helm environment passes the URL, issuer, client identity, group mapping inputs, display label, claim name, and email requirement into the pod only when OIDC is enabled.

At startup, the server loads and validates the OIDC configuration, constructs an encrypted transaction-cookie codec, creates a transaction store against the normal repository, and builds the provider client with a ten-second HTTP timeout. Provider discovery is deferred rather than executed during construction. That avoids turning server startup into a hard dependency on an external discovery endpoint, and it gives the application a better failure mode during temporary identity-provider trouble. A focused test verifies that constructing enabled OIDC services produces no discovery requests. Disabled configuration returns empty services, without attempting to initialize any OIDC-specific dependency.

The login transaction is protected with an encrypted cookie and repository-backed state, rather than loose request parameters. The callback verification covers the normal OIDC security properties: ID-token signature, issuer, audience, and nonce. DurpDeploy does not persist provider access tokens, authorization codes, or raw claims. That is an intentional boundary. The deployment application needs a verified identity and role mapping, not a second token store with its own rotation, leakage, and revocation problems.

The email policy is explicit because email is the account-linking key. By default, the callback requires the literal JSON boolean email_verified: true. This rejects missing, null, string, and numeric forms instead of coercing claims into something convenient but ambiguous. I added an escape hatch, DURPDEPLOY_OIDC_REQUIRE_EMAIL_VERIFIED=false, for environments where Authentik independently establishes email ownership. Even in that mode, the claim must still be a present literal boolean. It accepts true or false, not malformed data. This is a deliberate reduction in identity assurance, not a silent fallback.

On successful login, DurpDeploy first looks for an existing local user with the same email. If it finds one, it links that identity to the local account. If it does not, it creates an OIDC-only user with an empty password. That makes the authentication mode clear: newly provisioned OIDC users continue through OIDC, while existing password users keep the local password path. The local password form remains available alongside the SSO control, and password login uses the most recently stored local role.

Group mapping is evaluated with a deterministic privilege order: admin, then deployer, then viewer. This is important when an upstream group configuration accidentally puts a person in multiple application groups. The most privileged matching role wins rather than relying on provider claim order. Each successful OIDC login synchronizes the stored display name, email address, and role. When a role changes, existing browser sessions for that user are deleted, preventing a session created under older privileges from quietly retaining access.

There are some intentional limits that are documented rather than hidden. Group removal takes effect on the user’s next OIDC login. There is no SCIM integration or identity-provider back-channel deprovisioning in this first implementation. Logout is local only: it clears the DurpDeploy browser session but does not log the user out of the provider. OIDC does not authenticate API bearer tokens, and a provider outage does not take down existing sessions, the health endpoint, bearer-token API access, or the local password login. An OIDC-created user can still be recovered through normal administrator-driven local user recovery, but there is no self-service password reset for an account whose password is intentionally empty.

I also updated local developer ergonomics around these settings. The development server target now exports the OIDC-related variables from the environment file when present, while keeping the secret-key handling unchanged. This is enough to exercise SSO locally without creating a separate configuration layer that would drift away from production.

DurpDeploy: MFA and Authentication Test Coverage

Before the OIDC merge, I completed the larger MFA work that now sits alongside it. Browser MFA protects browser sessions only. API tokens remain single bearer credentials and are not made MFA-aware by this change, and an MFA reset does not revoke those tokens. That distinction is documented because confusing session protection with token protection creates false confidence.

The MFA implementation includes the browser-facing enrollment and verification paths for TOTP, WebAuthn passkeys, and recovery codes, together with security reauthentication and administrative reset handling. Viewer accounts are normally blocked from state-changing requests by the CSRF and role middleware, but the security settings paths are explicitly allowed through that gate. A viewer still cannot administer the application, but can enroll, rename, remove, or recover their own second factor. Security self-service should not require deployment privilege.

The audit system received a structural cleanup to support these flows. Previously, the audit action mapping lived in the middleware implementation. I moved the route map into its own focused file and retained explicit route-to-action names for state-changing routes. That is boring on purpose. Audit labels such as mfa_login_factor, mfa_recovery_use, reauthenticate, mfa_totp_enroll, mfa_passkey_add, mfa_passkey_rename, mfa_passkey_delete, mfa_recovery_regenerate, mfa_disable, and mfa_admin_reset are stable, human-readable records rather than inferred names that change when a route is renamed.

Some protocol exchanges are deliberately mapped to an empty action. Beginning a WebAuthn ceremony, beginning TOTP enrollment, cancelling a login flow, and maintenance-style endpoints should not create misleading audit events simply because they are POST requests. The middleware also gained a request-scoped suppression mechanism and an override path for the admin MFA reset. The reset override records the target user ID, a dedicated entity type, and the supplied reason, rather than treating it as a generic user update.

Sensitive protocol fields remain outside the audit detail payload. The audit test constructs a request containing a challenge token, recovery code, and reset reason, then verifies that the reason is retained while the secret challenge and code do not appear in the JSON details. This is a small test with a useful failure mode: it catches accidental future logging of credentials or WebAuthn material.

I expanded the route coverage tests as well. They assert stable names for MFA routes, confirm reviewed route registrations are represented, and make sure obsolete routes are not left in the map. The middleware resolves the primary id through Chi route parameters before falling back to the first numeric path segment. That improves the entity ID attached to audit entries on nested routes, where the first number in a URL is not always the resource being changed.

The end-to-end test work was as important as the feature code. The GitLab pipeline now runs its lint, Go test, build, mobile-browser, and MFA jobs for merge-request events. The old generic test job is now named golang:test, while the build job explicitly depends on lint and that Go test stage. This makes the pipeline graph clearer and prevents a build from being treated as validation when its underlying test dependency has not run.

For MFA, I added an SQLite end-to-end job in the browser-capable Docker image and a separate parity job for PostgreSQL and SQL Server. The SQLite job builds the test image, creates a container, copies the checkout into it, installs the generated Swagger assets from the image, and runs make auth-mfa-e2e-sqlite. The parity job installs the needed Docker, Go, Node, and build tools, generates templates and Swagger files, then runs the PostgreSQL and SQL Server suites. Both jobs serialize through resource_group: auth-mfa-e2e, which is the right tradeoff for Docker-in-Docker and browser-backed integration tests that are resource-heavy and likely to interfere when run concurrently.

The Makefile now separates normal application testing from an isolated end-to-end server run. make e2e-test targets an already-running instance and accepts DURPDEPLOY_BASE_URL plus a database path. make e2e-test-isolated builds and starts its own temporary SQLite server. The MFA suite builds on the isolated contract, then drives a deterministic browser test with a virtual authenticator. There are separate preparation and execution targets for SQLite HTTP, SQLite browser, PostgreSQL, and SQL Server parity. The point is not to have targets for their own sake. It is to make the database and browser test boundaries explicit, so a developer can run the narrow check that matches a change and CI can run the full matrix.

Failure diagnostics are now intentionally capped at 32 KiB and only emitted when a job fails. The mobile browser job no longer copies a persistent artifact directory for every pass. Instead, it reads small readability JSON reports from the running container on failure, then removes the test container. The MFA jobs do the same for redacted summaries, logs, and traces. This keeps normal pipelines quieter and avoids preserving unnecessary data, while still leaving enough evidence to debug a failure.

I also documented the expected verification discipline in the repository guidance: focused tests during development are useful, but the relevant complete Go and end-to-end suites must pass before declaring the work done. That sounds obvious, but it is worth making explicit for a codebase where authentication behavior spans handlers, generated templates, browser interaction, and multiple SQL backends.

Local HTTPS and Quality Analysis

The development workflow now has an ephemeral Caddy HTTPS proxy in front of the hot-reload server. make dev, and the matching PostgreSQL and SQL Server development commands, leave the app listening at http://localhost:8080 but expose it at https://localhost:8443. Caddy uses its internal development CA, so a browser can accept the local warning or curl can use -k. The proxy is removed when the development command exits.

This is mainly in support of modern browser security behavior and MFA or OIDC flows that need to be exercised over HTTPS. It avoids making every developer install and configure Caddy globally. The proxy container name, port, and backend are configurable through DEV_HTTPS_PROXY_CONTAINER, DEV_HTTPS_PROXY_PORT, and DEV_HTTPS_PROXY_BACKEND. On Linux it relies on Docker’s host-gateway support, and startup fails clearly if the host backend cannot be reached.

I also added SonarCloud analysis configuration. The GitHub workflow runs on pushes to main or master and on opened, synchronized, or reopened pull requests. It checks out the full history with fetch-depth: 0, then runs the pinned SonarQube scan action with the SONAR_TOKEN secret. The project properties identify the DurpDeploy project and organization, while leaving the source defaults uncomplicated. This does not replace tests, but it adds static-quality and trend visibility to a repository that grew substantially this week.

GitOps: Stabilizing Authentik and Longhorn

The Talos-side GitOps changes began with actual resource pressure. I added resource requests and limits for Longhorn Manager: a 250m CPU request, 256Mi memory request, and 512Mi memory limit. Longhorn Manager runs as a DaemonSet and is core storage control-plane software, so an OOM kill is not an isolated application failure. It can affect volume reconciliation and recovery across nodes. Defining a memory ceiling gives the scheduler a usable placement signal and keeps a runaway process from taking more of a node than intended.

Authentik received two related adjustments. The embedded PostgreSQL configuration now has a liveness probe with a 120-second initial delay, 30-second period, 10-second timeout, and failure threshold of 10. The readiness probe starts after 30 seconds, runs every 10 seconds, has the same 10-second timeout, and allows six failures. Database startup is not instantaneous, especially on persistent storage after a pod move or recovery. These settings distinguish a slow-but-progressing database from one that is genuinely unavailable, without putting the pod into a restart loop while it initializes.

The Authentik worker resources were also raised after OOM behavior: the memory request moved from 128Mi to 256Mi, and the limit from 512Mi to 1Gi. The request gives Kubernetes a more realistic scheduling baseline, while the one-gibibyte cap provides burst headroom without abandoning containment. I will still treat these as calibrated starting values, not theoretical truth. Resource settings need metrics and real workload history behind them.

Finally, I disabled Authentik’s automatically created outpost ingress. The embedded outpost is reached through a configured Traefik forward-auth path, so allowing the chart to independently create ingress resources risks two competing exposure paths. The GitOps value is now authentik.outposts.ingress.enabled: false, keeping ingress ownership explicit.

GitOps: Traefik and Cluster Layout

I added two Traefik Middleware resources in the Talos infrastructure chart. authentik-proxy-provider uses Traefik forwardAuth to call the embedded Authentik outpost service at port 9000, including a redirect parameter that reconstructs the incoming scheme, host, and request URI. It trusts forwarded headers and returns the identity headers needed upstream: username, groups, email, name, UID, JWT, and Authentik metadata covering JWKS, outpost, provider, app, and version.

The second Middleware is a network whitelist. It allows RFC 1918 address space, specifically 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. This is a practical internal-access control for services that should be reachable from homelab networks but not from arbitrary public addresses. It is not a replacement for authentication. The normal model is to compose the IP boundary with forward authentication when an application needs both controls.

The biggest GitOps change by file count was organizational rather than behavioral. I renamed the current Talos DMZ tree to dmz, moved the previous tree to dmz-old, and then moved that retired cluster definition under old/dmz-old. The sequence preserved Git history as renames rather than copying charts into fresh directories. The final active naming makes the current deployment target obvious, while the legacy manifests stay available without competing with active environment paths.

The moved tree includes the GitLab CI configuration and chart wrappers for Authentik, cert-manager, CrowdSec, external-dns, external-secrets, GitLab Runner, internal proxy, Longhorn, and Traefik. Keeping old manifests in old/ is useful during retirement and rollback analysis, but the path makes their status unmistakable. This is safer than deleting configuration immediately, and much less confusing than leaving a former cluster alongside the active one under an ambiguous name.

Looking Ahead

The next priority is to validate the OIDC implementation against the production Authentik configuration, particularly exact callback registration, group-claim shape, email-verification behavior, and session invalidation after a role change. I also need to decide whether next-login deprovisioning is sufficient for this environment or whether SCIM or a provider callback becomes necessary later.

On the cluster side, I will watch Authentik worker memory, PostgreSQL probe behavior, and Longhorn Manager consumption before changing the new limits again. The middleware work should be applied selectively to services with clear internal-only or authenticated access requirements, rather than becoming a blanket annotation. Finally, the old/dmz-old archive gives me a clean line between active and retired cluster configuration, and I intend to keep that boundary intact as the remaining migration work settles.