RSSAmplifier

RogueSecurity Musings Blog · May 19, 2025

Leveraging Authelia for OIDC Single Sign-On (SSO) with Headscale

0
Sign in to vote or save

StarkZarn · roguesecurity.dev

A bonus article in the self-hosted Headscale series! After implementing it myself with a few trials and tribulations, I decided to share how exactly to make Authelia work as an OIDC identity provider for Headscale.

Why OIDC?

Headscale works great on its own. You define users locally, the get saved into the sqlite db on disk, you can reference them in ACLs, everything just works. What more is there to want?

In steps SSO… We’ll quote Okta here, because they’re experts.

Single sign-on (SSO) is an authentication tool that enables users to securely access multiple applications and services using one set of credentials, eliminating the need to remember different passwords for each service. […] SSO puts an end to the days of remembering and entering multiple passwords and eliminates the frustration of having to reset forgotten passwords. Users can also access a range of platforms and apps without having to log in each time.

Got it, so that makes a ton of sense in a large enterprise-like environment, where you have user churn, you’re hiring and firing and need to provision and deactivate accounts from a single source of truth, and that’s the important part here — a single source of truth. You can extend this configuration to be an identity provider for lots of your services, and emulate that “enterprise-like” environment. It gives you granular control, and best of all: it gives you multi-factor authentication! That’s right, our headscale setup with have MFA after this.

There’s the added benefit of some easier interfacing with other tools too, like third party webUIs for headscale, such as Headplane, which integrates very well with OIDC.

Info

As of headscale release 0.26, several breaking changes were made as part of a code-base cleanup and efficiency push. One of these is that all user objects in your ACLs must include an @ in their string. This makes perfect sense when you’re passing an email address as part of your OIDC claim, but for locally defined users, it means going through and manually adding an @ to the end of their username everywhere it appears in your ACLs. Using OIDC lends itself to the new method of referencing users.

If this is the first article you’re reading, consider yourself lucky as you can apply this to your new headscale setup without any rework!

Let’s get into it!

Adding Authelia

Note

As this is a limited deployment of SSO for me, I have chosen to host authelia on the same VPS that I’m hosting my headscale server on. This probably makes sense if you’re only using your identity provider externally, but if you’re relying on it for internal services too, maybe consider hosting it on-prem and exposing it to the internet via a Wireguard tunnel with traefik as a reverse proxy i.e. a homemade Cloudflare tunnel. Maybe that’s a blog post for another day. Leave a comment if you’d like to see that.

Assumptions:
  • You’re running a server with a publicly accessible reverse proxy.
  • Your server operating system has podman installed and supports quadlets.
  • You already have a headscale stack running.
  • You’re running traefik in podman already as a reverse proxy.

Configure a container for Authelia

In your /etc/containers/systemd directory, create a new file for our authelia config:

echo '[Volume]' | sudo tee authelia-config.volume

Now the container definition. The following is what goes in your authelia.container file.

[Unit]
Description=Authelia Container
After=network-online.target traefik.service
Requires=traefik.service

[Container]
ContainerName=authelia
HostName=authelia
Image=ghcr.io/authelia/authelia:latest
Network=traefik.network
Volume=authelia-config.volume:/config:Z
Environment=TZ=America/New_York

Secret=authelia_session_secret,type=mount,target=/run/secrets/authelia_session_secret
Secret=authelia_storage_encryption_key,type=mount,target=/run/secrets/authelia_storage_encryption_key
Secret=authelia_smtp_password,type=mount,target=/run/secrets/authelia_smtp_password
Secret=authelia_jwt_secret,type=mount,target=/run/secrets/authelia_jwt_secret
Secret=authelia_oidc_hmac,type=mount,target=/run/secrets/authelia_OIDC_HMAC


Environment=AUTHELIA_SESSION_SECRET_FILE=/run/secrets/authelia_session_secret
Environment=AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/run/secrets/authelia_storage_encryption_key
Environment=AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/run/secrets/authelia_smtp_password
Environment=AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/run/secrets/authelia_jwt_secret
Environment=AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/run/secrets/authelia_OIDC_HMAC

AutoUpdate=registry

[Service]
Restart=always

[Install]
WantedBy=default.target

Okay, there are a number of secrets here… We need to create secrets for:

  • authelia_session_secret
  • authelia_storage_encryption_key
  • authelia_smtp_password
  • authelia_jwt_secret
  • authelia_OIDC_HMAC

The SMTP password secret should be self-explanatory, that’s going to have a plaintext password for the service mailbox you’re using to send Authelia emails on behalf of.

Create a temporary file to store the secret value, that way it doesn’t accidentally end up in your bash history or anywhere else: sudo vim smtp.secret

# Change this to your email account's password and don't include this comment!
A sup3r secure passphrase123.

---
:wq

Create your secret: sudo podman secret create authelia_smtp_password ./smtp.secret

Make sure you delete the file securely afterwards: sudo shred -u smtp.secret

The rest of the secrets are going to be generated values. The Authelia Documentation does a very good job of covering environment variables and secrets. I’m not going to cover the secret creation command each time, but you can extrapolate from the above example.

This is the command we’ll use to generate our random strings:

LENGTH=64
tr -cd '[:alnum:]' < /dev/urandom | fold -w "${LENGTH}" | head -n 1 | tr -d '\n' ; echo

Make one of those random strings for each of the above secrets, then create each secret as above.

The config file

Let’s reload our systemd configuration and get our volume setup so we can create a config file.

sudo systemctl daemon-reload

sudo systemctl start authelia-config-volume.service

Now head over to: /var/lib/containers/storage/volumes/systemd-authelia-config/_data/ and we’ll get started on config.

# yamllint disable rule:comments-indentation
---
###############################################################################
##                           Authelia Configuration                          ##
###############################################################################

theme: "auto"
default_2fa_method: 'totp'

server:
  address: "tcp://:9091/"
  asset_path: "/config/assets/"
  disable_healthcheck: false

telemetry:
  metrics:
    enabled: false

totp:
  disable: false
  issuer: "cloudauth.yourDomain.tld"
  algorithm: 'SHA1'
  digits: 6
  period: 30
  skew: 1
  secret_size: 32
  disable_reuse_security_policy: false

identity_validation:
  reset_password:
    jwt_lifespan: "5 minutes"
    jwt_algorithm: "HS256"

  elevated_session:
    code_lifespan: '5 minutes'
    elevation_lifespan: '10 minutes'
    characters: 8
    require_second_factor: false


authentication_backend:
  password_change:
    disable: false
  password_reset:
    disable: false

  file:
    path: "/config/users_database.yml"
    watch: true
    search:
      email: false
      case_insensitive: false
    password:
      algorithm: "argon2"
      argon2:
        variant: "argon2id"
        iterations: 3
        memory: 65536
        parallelism: 4
        key_length: 32
        salt_length: 16

password_policy:
  standard:
    enabled: false
    min_length: 16
    require_uppercase: true
    require_lowercase: true
    require_number: true
    require_special: true

  zxcvbn:
    enabled: true
    min_score: 3

access_control:
  default_policy: "two_factor"

session:
  cookies:
    - name: "authelia_session"
      domain: "yourDomain.tld"
      authelia_url: "https://cloudauth.yourDomain.tld"

regulation:
  modes:
    - "user"
  max_retries: 4
  find_time: "5 minutes"
  ban_time: "15 minutes"

storage:
  local:
    path: "/config/db.sqlite3"

notifier:
  disable_startup_check: true
  smtp:
    address: "submissions://your.emailprovider.tld:465"
    username: "cloudauth@yourDomain.tld"
    sender: "CloudAuth <cloudauth@yourDomain.tld>"
    identifier: "cloudauth.yourDomain.tld"

identity_providers:

  oidc:

    claims_policies:
      default:
        id_token: ['groups', 'email', 'email_verified', 'alt_emails', 'preferred_username', 'name', 'picture']

    jwks:
      - key_id: "GenerateThisSecretLikeBefore"
        ## The key algorithm used with this key.
        algorithm: "RS256"
        ## The key use expected with this key. Currently only 'sig' is supported.
        use: "sig"
        key: |
          -----BEGIN PRIVATE KEY-----
          DATA HERE
          This should be the private_key.pem value of a key generated with
          openssl req -newkey rsa:2048 -nodes -keyout private_key.pem -x509 -days 3650 -out public_certificate.pem
          -----END PRIVATE KEY-----

    authorization_policies:
      headscale:
        default_policy: "two_factor"
        rules:
          - policy: "deny"
            subject: "group:denied"

    lifespans:
      access_token: "8 hour"
      authorize_code: "1 minute"
      id_token: "1 hour"
      refresh_token: "90 minutes"

    clients:
      - client_id: "GenerateThisSecretLikeBefore" # You'll need this again on the headscale config
        client_name: "Tailscale"
        client_secret: 'GenerateThisSecretLikeBefore' # You'll need this again on the headscale config
        claims_policy: default
        public: false
        redirect_uris:
          - "https://tailscale.yourDomain.tld/oidc/callback"
        scopes:
          - 'openid'
          - 'groups'
          - 'email'
          - 'profile'
        authorization_policy: 'headscale'
        require_pkce: true

Now we need our local users file: users-database.yml:

###############################################################
#                         Users Database                      #
###############################################################

# This file can be used if you do not have an LDAP set up.

users:
  authelia:
    disabled: true
    displayname: "Test User"
    password: "Your Argon Hash Value Here"  # Password is 'authelia'
    email: authelia@authelia.com
    groups:
      - admins
      - dev

Tip

Authelia has a built-in utility to generate hashes for you: sudo podman run --rm -it ghcr.io/authelia/authelia:latest authelia crypto hash generate argon2

Setup your DNS A record and give Traefik a router to properly pass traffic to your Authelia instance and give it whirl!

Authelia Login Screen

You should be able to sign in with whatever account you created.

Tip

Authelia also supports LDAP sync if you already have another IdP setup in your environment, like OpenLDAP, LLDAP, Active Directory, or FreeIPA!

I’ll leave it as “an exercise for the reader” to create the other users you’ll use for headscale endpoints.

Reconfiguring Headscale to Use Authelia

Wherever you have your headscale config stored (maybe /var/lib/containers/storage/volumes/systemd-headscale-config/_data…?), we’re going to head over there and add some config for OIDC.

Tip

It’s always good practice to back things up before you edit them for the first time, but don’t worry, adding OIDC won’t affect any of your existing users. Headscale can perfectly happily have local users and OIDC users. You can’t have the same username shared between the two, but that point is moot at the moment.

This is the stanza you’re going to want to add (with your values, of course) to your headscale config.yaml file:

oidc:
  client_id: 'TheSameSecretFromAuthelia'
  client_secret_path: '/run/secrets/headscale_oidc_secret' # This should be the same secret value from the authelia config
  extra_params: 
    - domain_hint: yourDomain.tld
  issuer: 'https://cloudauth.yourDomain.tld'
  scope:
    - openid
    - profile
    - email
  pkce:
    enabled: true
    method: S256

oidc here is a parent key, so it’s not indented at all, if you’re wondering where to put this config snippet. The headscale example config shows you all the different values that could go here.

Now the config change isn’t the only thing. Since we’re adding a podman secret, we need to define that secret in the .container file.

Head over to your headscale.container file and let’s define the secret. Add the following line under the [Container] section tag somewhere

Secret=headscale_oidc_secret,type=mount,target=/run/secrets/headscale_oidc_secret

Now we should be good on the headscale side. Reload your systemd config: sudo systemctl daemon-reload, and restart headscale sudo systemctl restart headscale. If all went well, headscale should be back up and running, exactly as it was before, with all users from before working.

Let’s get an OIDC user in there.

Testing and OIDC Authentication

On a device that you want to re-enroll, or a device you have enrolled yet with Tailscale, execute the exact same login process as described in my first blog post. I’m going to be on Linux here so I’ll run:

sudo systemctl start tailscaled
sudo tailscale login --login-server https://tailscale.yourDomain.tld

but this time, you should see a URL spit out that you’ll visit to authenticate!

An OIDC-enabled tailscale login prompt

Copy/paste that into your browser and you should be good to go! You’ll get prompted to enroll an MFA device for you TOTP second factor the first time you sign in (with whatever user), and email confirmation will be required for that.

Now just go an extend the config by creating more users in Authelia, and adding them to Tailscale. You can apply ACLs exactly as you did before and carry on as normal, but with more security, thanks MFA!

More than a password graphic from CISA Credit to CISA.gov for the graphic. ❤️ CISA

Support

If you’ve enjoyed this, consider helping support the infrastructure to run it.

Buy me a coffee or three with the button below.

Buy Me a Coffee at ko-fi.com

Read the original on roguesecurity.dev

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.