Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OIDC-Authenticated Device Management

This tutorial builds an OIDC-authenticated management stack on AtomixOS using three components:

  • Caddy with AuthCrunch – reverse proxy with Microsoft Entra OIDC login and JWT-based authorization
  • Cockpit-ws – browser-based device management console
  • Admin-only route policy – allows administrators to reach Cockpit while leaving room for user-facing application routes

The result is a single sign-on flow: users authenticate once through Entra ID, and Caddy only exposes the Cockpit management console to admin users.

This tutorial is designed for local device management on a LAN. Caddy uses its internal certificate authority instead of Let’s Encrypt, so the device does not need a publicly routed domain or inbound internet access.

Contents

Prerequisites

The example bundle uses Microsoft Entra by default because Entra group claims map cleanly to admin/user roles. You can use any AuthCrunch-supported OIDC provider by changing the Caddyfile identity provider block, callback URI, and role mapping rules.

Microsoft Entra App Registration

  1. In the Azure portal, open Microsoft Entra ID > App registrations

  2. Select New registration

  3. Set the redirect URI to:

    https://<GATEWAY_DOMAIN>/auth/oauth2/azure/authorization-code-callback
    
  4. Note the Application (client) ID and Directory (tenant) ID

  5. Under Certificates & secrets, create a new client secret and copy its value

  6. Under Token configuration > Add groups claim, select Security groups

  7. Create two Entra security groups:

    • AtomixOS-Admins – full device administration
    • AtomixOS-Users – read-only monitoring access
  8. Assign users to the appropriate groups

Google OAuth Client

For Google, create an OAuth client in Google Cloud Console instead of an Entra app registration:

  1. Open APIs & Services > Credentials

  2. Create an OAuth client ID for a web application

  3. Add this authorized redirect URI:

    https://<GATEWAY_DOMAIN>/auth/oauth2/google/authorization-code-callback
    
  4. Note the client ID and client secret

  5. Decide how to assign admin access. Common options are a Google Workspace group claim, a hosted-domain claim, or an explicit email allow-list in the AuthCrunch transform rules.

Then replace the Entra identity provider block in the Caddyfile with a Google provider block. The exact AuthCrunch driver options may vary by AuthCrunch version; the important values are the provider name (google), realm (google), client ID, client secret, and callback URI path.

oauth identity provider google {
	realm google
	driver google
	client_id {env.GOOGLE_CLIENT_ID}
	client_secret {env.GOOGLE_CLIENT_SECRET}
	scopes openid email profile
}

Also update enable identity provider azure to enable identity provider google and update transform rules from match realm azure to `match realm

Architecture

graph TD
    lan((Local LAN browser)) -- "ports 80, 443" --> caddy

    subgraph caddy["Caddy + AuthCrunch"]
        ca1["/auth* → OIDC portal"]
        ca2["/cockpit/* → admin-only reverse proxy"]
        ca3["/app/* → user application routes"]
    end

    caddy -- "localhost:9090" --> cockpit

    subgraph cockpit["Cockpit-ws"]
        co1["--local-session"]
        co2["cockpit-bridge"]
        co3["host D-Bus and Podman sockets"]
    end

Authentication Flow

  1. User navigates to https://<GATEWAY_DOMAIN>/cockpit/
  2. Caddy checks for a valid JWT cookie; if absent, redirects to /auth/
  3. AuthCrunch initiates Entra OIDC login
  4. After authentication, AuthCrunch maps Entra groups to roles:
    • AtomixOS-Admins group receives the authp/admin role
    • AtomixOS-Users group receives the authp/user role
  5. AuthCrunch issues a JWT cookie with the mapped roles
  6. Caddy validates the JWT and allows /cockpit/* only for authp/admin
  7. Cockpit runs behind Caddy with --local-session; Cockpit performs no second login and relies on Caddy for authentication and authorization

Bundle Structure

example/caddy-oidc/
config.toml
files/
  caddy/
    Caddyfile
  cockpit/
    Containerfile

Substitute the placeholder values in config.toml, package this directory, and provision the device. The Caddyfile and generated Cockpit configuration read those values from container environment variables.

Placeholder Values

Replace these values before provisioning:

PlaceholderWhereDescription
<SSH_PUBLIC_KEY>config.tomlYour SSH public key for admin access
<AZURE_TENANT_ID>config.tomlEntra directory (tenant) ID
<AZURE_CLIENT_ID>config.tomlApp registration client ID
<AZURE_CLIENT_SECRET>config.tomlApp registration client secret
<JWT_SHARED_KEY>config.tomlShared HMAC-SHA256 signing key
<GATEWAY_DOMAIN>config.tomlLocal DNS name for the device
<ENTRA_ADMIN_GROUP_NAME>config.tomlEntra group name for admin role

If you switch to Google or another provider, replace the Azure placeholders with that provider’s client ID/secret variables and update the Caddyfile environment entries accordingly.

Generate the JWT shared key with:

openssl rand -base64 32

Configuration Files

Local DNS and TLS

The browser must resolve <GATEWAY_DOMAIN> to the device’s LAN address. Use one of these local options:

  • Add a DNS record on your LAN router or development DNS server
  • Add a hosts-file entry on the workstation you use to manage the device
  • Use another local name resolution mechanism that maps the name to the device IP address

For example, if the gateway is reachable at 172.20.30.1:

172.20.30.1 gateway.example.com

Caddy serves HTTPS for this name with tls internal. That avoids public ACME validation and works even when the domain is not reachable from the internet. Browsers will not trust Caddy’s local CA by default; either trust the Caddy root CA from the caddy-data volume on your management workstation or accept the browser warning for local testing.

config.toml

The config defines two rootful containers, a network, a volume, and a build:

version = 1

[users.admin]
isAdmin = true
ssh_key = "<SSH_PUBLIC_KEY>"

[network.firewall.inbound.wan]
tcp = [80, 443]

[network.ntp]
servers = ["time.cloudflare.com"]

[activation]
required = ["caddy-gateway", "cockpit-ws"]

# -- Networks --------------------------------------------------------

[containers.network.management]
[containers.network.management.Network]
Subnet = "10.89.1.0/24"

# -- Volumes ---------------------------------------------------------

[containers.volume.caddy-data]
[containers.volume.caddy-data.Volume]
Driver = "local"

# -- Builds ----------------------------------------------------------

[containers.build.cockpit-ws]
[containers.build.cockpit-ws.Build]
File = "${FILES_DIR}/cockpit/Containerfile"
ImageTag = "localhost/cockpit-ws:latest"
Network = "host"

# -- Containers ------------------------------------------------------

[containers.container.caddy-gateway]
privileged = true

[containers.container.caddy-gateway.Unit]
Description = "Caddy gateway with AuthCrunch OIDC"

[containers.container.caddy-gateway.Container]
Image = "ghcr.io/authcrunch/authcrunch:latest"
Environment = [
  "GATEWAY_DOMAIN=<GATEWAY_DOMAIN>",
  "AZURE_TENANT_ID=<AZURE_TENANT_ID>",
  "AZURE_CLIENT_ID=<AZURE_CLIENT_ID>",
  "AZURE_CLIENT_SECRET=<AZURE_CLIENT_SECRET>",
  "ENTRA_ADMIN_GROUP_NAME=<ENTRA_ADMIN_GROUP_NAME>",
  "JWT_SHARED_KEY=<JWT_SHARED_KEY>",
]
Volume = [
  "${FILES_DIR}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro",
  "${FILES_DIR}/caddy/ui:/etc/caddy/ui:ro",
  "caddy-data:/data",
]

[containers.container.caddy-gateway.Install]
WantedBy = ["multi-user.target"]

[containers.container.cockpit-ws]
privileged = true

[containers.container.cockpit-ws.Unit]
Description = "Cockpit web console behind OIDC"
After = ["cockpit-ws-build.service"]
Requires = ["cockpit-ws-build.service"]

[containers.container.cockpit-ws.Container]
Image = "localhost/cockpit-ws:latest"
Pull = "never"
PodmanArgs = ["--pid=host", "--privileged"]
Environment = [
  "GATEWAY_DOMAIN=<GATEWAY_DOMAIN>",
]
Volume = [
  "/run/dbus/system_bus_socket:/run/dbus/system_bus_socket",
  "/run/podman/podman.sock:/run/podman/podman.sock",
  "/run/systemd:/run/systemd",
  "/run/udev:/run/udev:ro",
  "/:/host",
  "/var/log/journal:/var/log/journal:ro",
  "/etc/os-release:/etc/os-release:ro",
]

[containers.container.cockpit-ws.Install]
WantedBy = ["multi-user.target"]

Key points:

  • Caddy is privileged = true because it binds ports 80/443
  • Cockpit-ws is privileged = true because it runs a local management session with host D-Bus, systemd, journal, and Podman sockets mounted into the container
  • The cockpit-ws container depends on its build service via After and Requires
  • Pull = "never" prevents Podman from trying to fetch the locally built localhost/cockpit-ws:latest tag from a registry
  • The cockpit-ws build uses Network = "host" to avoid Podman build-time netavark/nftables setup on constrained device images
  • The ${FILES_DIR} token is replaced at provision time with the path to the extracted bundle files
  • GATEWAY_DOMAIN is passed to both containers; Caddy uses it for the site address and Cockpit uses it to generate the real-device and VM-forwarded origins in /etc/cockpit/cockpit.conf
  • Caddy uses tls internal, so HTTPS is local-only and does not require public DNS or Let’s Encrypt validation
  • The management network is defined for future use when containers move off host networking

Caddyfile

{
	http_port 80
	https_port 443
	admin off

	order authenticate before respond
	order authorize before basicauth

	security {
		oauth identity provider azure {
			realm azure
			driver azure
			tenant_id {env.AZURE_TENANT_ID}
			client_id {env.AZURE_CLIENT_ID}
			client_secret {env.AZURE_CLIENT_SECRET}
			scopes openid email profile
		}

		authentication portal myportal {
			crypto default token lifetime 3600
			crypto key sign-verify {env.JWT_SHARED_KEY}
			enable identity provider azure

			ui {
				theme basic
				template login /etc/caddy/ui/login.template
				template portal /etc/caddy/ui/portal.template
				template generic /etc/caddy/ui/generic.template
				custom css path /etc/caddy/ui/atomixos-auth.css
				custom js path /etc/caddy/ui/atomixos-auth.js
				static_asset "assets/images/atomixos-logo.png" "image/png" /etc/caddy/ui/atomixos-logo.png
				static_asset "assets/images/cockpit.svg" "image/svg+xml" /etc/caddy/ui/cockpit.svg
				static_asset "assets/images/microsoft-entra.svg" "image/svg+xml" /etc/caddy/ui/microsoft-entra.svg
				static_asset "assets/images/user.svg" "image/svg+xml" /etc/caddy/ui/user.svg

				links {
					"Admin Console" /cockpit/ icon "las la-server"
				}
			}

			transform user {
				match realm azure
				action add role authp/user
			}

			transform user {
				match realm azure
				match roles {$ENTRA_ADMIN_GROUP_NAME}
				action add role authp/admin
			}
		}

		authorization policy user-policy {
			set auth url /auth/
			set access_token cookie name AUTHP_ACCESS_TOKEN
			crypto key verify {env.JWT_SHARED_KEY}
			allow roles authp/admin authp/user
			validate bearer header
			inject headers with claims
		}

		authorization policy admin-policy {
			set auth url /auth/
			set access_token cookie name AUTHP_ACCESS_TOKEN
			crypto key verify {env.JWT_SHARED_KEY}
			allow roles authp/admin
			validate bearer header
			inject headers with claims
		}
	}
}

{$GATEWAY_DOMAIN} {
	tls internal

	redir / /cockpit/ 302
	redir /cockpit /cockpit/ 302
	header /auth/assets/* {
		Cache-Control "no-store, no-cache, must-revalidate"
		Pragma "no-cache"
		Expires "0"
		defer
	}

	route /auth* {
		authenticate with myportal
	}

	route /cockpit/* {
		header Cache-Control "no-store"
		header Pragma "no-cache"
		authorize with admin-policy
		reverse_proxy localhost:9090 {
			header_up Authorization "Bearer {http.request.cookie.AUTHP_ACCESS_TOKEN}"
		}
	}

	# Add user-facing applications here. They can use user-policy to allow
	# both admin and user roles.
	# route /app/* {
	# 	authorize with user-policy
	# 	reverse_proxy localhost:8080
	# }
}

Key points:

  • The order directives register the authenticate and authorize handlers
  • The identity provider block configures Entra OIDC via the azure driver
  • The portal issues JWTs signed with the shared key
  • The portal explicitly lists Cockpit as an application link; AuthCrunch does not discover Caddy routes automatically
  • transform user blocks assign base roles (authp/user) and promote admin group members to authp/admin
  • admin-policy restricts /cockpit/* to authp/admin
  • user-policy is provided for user-facing applications that should allow both authp/admin and authp/user
  • tls internal tells Caddy to issue a certificate from its local CA instead of using public ACME/Let’s Encrypt
  • / redirects to /cockpit/, and /cockpit normalizes to /cockpit/
  • GATEWAY_DOMAIN and ENTRA_ADMIN_GROUP_NAME come from container environment variables set in config.toml

Containerfile

FROM quay.io/fedora/fedora:42

RUN dnf install -y --setopt=install_weak_deps=False \
    cockpit-bridge \
    cockpit-files \
    cockpit-podman \
    cockpit-system \
    cockpit-ws \
    openssh-clients \
    podman \
    && dnf clean all

COPY rootfs/ /

RUN chmod 0755 \
    /usr/local/bin/cockpit-auth-atomixos \
    /usr/local/bin/cockpit-beiboot-bridge \
    /usr/local/bin/start-cockpit \
    && mkdir -p /usr/share/cockpit/branding/default /usr/share/cockpit/branding/fedora \
    && ln -sf ../../static/atomixos.css /usr/share/cockpit/branding/default/branding.css \
    && ln -sf ../../static/atomixos.css /usr/share/cockpit/branding/fedora/branding.css \
    && ln -sf ../../static/atomixos-logo.png /usr/share/cockpit/branding/default/logo.png \
    && ln -sf ../../static/atomixos-logo.png /usr/share/cockpit/branding/fedora/logo.png \
    && python3 /usr/local/bin/patch-cockpit.py

CMD ["/usr/local/bin/start-cockpit"]

The custom image adds Cockpit’s bridge and management modules, then starts cockpit-ws with --local-session. Cockpit itself does not authenticate users; Caddy’s admin-only OIDC policy protects the route. The startup command writes /etc/cockpit/cockpit.conf from GATEWAY_DOMAIN, so the example only requires editing config.toml.

Building and Applying

Package the bundle as a tarball:

# Edit config.toml with your values
tar --zstd -cvf config.tar.zst -C <repo>/example/caddy-oidc .

Apply to the device using the bootstrap server or USB provisioning. See Provisioning for details.

Cockpit-Podman

The Cockpit Podman integration (cockpit-podman) lets operators manage containers through the Cockpit UI. In this example it is installed into the Cockpit container and uses the mounted host Podman socket at /run/podman/podman.sock.

A future NixOS module could make Cockpit a native host service instead of a containerized admin application:

{ pkgs, ... }:
{
  environment.systemPackages = [ pkgs.cockpit-podman ];
}

This is outside the scope of the tutorial config bundle and requires rebuilding the AtomixOS base image.

Security Considerations

This tutorial uses HS256 (symmetric) JWT signing for simplicity. For production deployments:

  • Use public DNS and public certificates if exposing the device outside a trusted local network. The tutorial intentionally uses Caddy internal TLS for local management, not internet deployment.
  • Use asymmetric keys (RS256/ES256) instead of a shared HMAC secret. AuthCrunch supports RSA and ECDSA key pairs.
  • Rotate secrets regularly. The JWT_SHARED_KEY and Azure client secret should be rotated on a schedule.
  • Use secret files instead of environment variables for sensitive values. Podman supports --secret mounts that avoid exposing secrets in Quadlet files on disk.
  • Pin image tags in production. The tutorial uses :latest for convenience; production should pin to specific versions.
  • Restrict Cockpit access. The viewer user should have minimal permissions. Consider using Cockpit’s cockpit.conf [Ssh-Login] restrictions.