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
-
In the Azure portal, open Microsoft Entra ID > App registrations
-
Select New registration
-
Set the redirect URI to:
https://<GATEWAY_DOMAIN>/auth/oauth2/azure/authorization-code-callback -
Note the Application (client) ID and Directory (tenant) ID
-
Under Certificates & secrets, create a new client secret and copy its value
-
Under Token configuration > Add groups claim, select Security groups
-
Create two Entra security groups:
AtomixOS-Admins– full device administrationAtomixOS-Users– read-only monitoring access
-
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:
-
Open APIs & Services > Credentials
-
Create an OAuth client ID for a web application
-
Add this authorized redirect URI:
https://<GATEWAY_DOMAIN>/auth/oauth2/google/authorization-code-callback -
Note the client ID and client secret
-
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
- User navigates to
https://<GATEWAY_DOMAIN>/cockpit/ - Caddy checks for a valid JWT cookie; if absent, redirects to
/auth/ - AuthCrunch initiates Entra OIDC login
- After authentication, AuthCrunch maps Entra groups to roles:
AtomixOS-Adminsgroup receives theauthp/adminroleAtomixOS-Usersgroup receives theauthp/userrole
- AuthCrunch issues a JWT cookie with the mapped roles
- Caddy validates the JWT and allows
/cockpit/*only forauthp/admin - 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:
| Placeholder | Where | Description |
|---|---|---|
<SSH_PUBLIC_KEY> | config.toml | Your SSH public key for admin access |
<AZURE_TENANT_ID> | config.toml | Entra directory (tenant) ID |
<AZURE_CLIENT_ID> | config.toml | App registration client ID |
<AZURE_CLIENT_SECRET> | config.toml | App registration client secret |
<JWT_SHARED_KEY> | config.toml | Shared HMAC-SHA256 signing key |
<GATEWAY_DOMAIN> | config.toml | Local DNS name for the device |
<ENTRA_ADMIN_GROUP_NAME> | config.toml | Entra 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 = truebecause it binds ports 80/443 - Cockpit-ws is
privileged = truebecause it runs a local management session with host D-Bus, systemd, journal, and Podman sockets mounted into the container - The
cockpit-wscontainer depends on its build service viaAfterandRequires Pull = "never"prevents Podman from trying to fetch the locally builtlocalhost/cockpit-ws:latesttag from a registry- The
cockpit-wsbuild usesNetwork = "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_DOMAINis 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
managementnetwork 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
orderdirectives register the authenticate and authorize handlers - The identity provider block configures Entra OIDC via the
azuredriver - 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 userblocks assign base roles (authp/user) and promote admin group members toauthp/adminadmin-policyrestricts/cockpit/*toauthp/adminuser-policyis provided for user-facing applications that should allow bothauthp/adminandauthp/usertls internaltells Caddy to issue a certificate from its local CA instead of using public ACME/Let’s Encrypt/redirects to/cockpit/, and/cockpitnormalizes to/cockpit/GATEWAY_DOMAINandENTRA_ADMIN_GROUP_NAMEcome from container environment variables set inconfig.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_KEYand Azure client secret should be rotated on a schedule. - Use secret files instead of environment variables for sensitive values.
Podman supports
--secretmounts that avoid exposing secrets in Quadlet files on disk. - Pin image tags in production. The tutorial uses
:latestfor convenience; production should pin to specific versions. - Restrict Cockpit access. The
vieweruser should have minimal permissions. Consider using Cockpit’scockpit.conf[Ssh-Login]restrictions.