Migrating Netbox From Keycloak to Kanidm

In this post I will take you on a journey in which I migrated my netbox OIDC auth from keycloak to kanidm. Parts of this blog can be used for templating a blueprint for other migrations, client setup though differs from software to software so you’d always need to consult the documentation for whatever it is that you’re trying to integrate with OIDC.

Background

Kanidm is a modern, Rust-based identity management platform that supports OAuth2/OIDC natively. Unlike Keycloak’s heavyweight Java stack, Kanidm is lightweight and CLI-managed. That very CLI based management fits better into my workflows as it’s more focused set of capabilities. Overall I feel like it has less knobs to fiddle and better defaults than Keycloak which drove my search for alternatives. Also I wasn’t using more complex setups like specific authentication workflows.

What changes:

  • Authentication Backend in netbox: social_core.backends.keycloak.KeyCloakOAuth2 will be replaced by the generic social_core.backends.open_id_connect.OpenIdConnectAuth
  • Config keys: All SOCIAL_AUTH_KEYCLOAK_* settings are replaced with SOCIAL_AUTH_OIDC_* equivalents
  • Endpoints: Kanidm uses standard OIDC discovery, so we only need to provide the discovery endpoint - no separate auth/token URLs anymore
  • Login button: The SSO button on the netbox login page will display a generic OIDC Reference instead of a product reference

What doesn’t change:

  • netbox users, permissions and data are untouched
  • The python-social-auth library stays the same - only the backend class changes

Time is of the essence

At some point in time I decided to go full deny all on the local firewall for the system that hosts netbox, i.e. both incoming and outgoing packets are dropped by default. In the transition I overlooked that OpenNTPd needs to access the servers that act as constraints. This lead to the clock being unsynched and the system had a drift of almost 2s (1843ms). Compared to kanidm the system lived in the past which caused some troubles as my iat (issued at time) claim was living in the future.

So when trying to authenticate the token’s iat was checked by python’s social-auth-core module which has a 1s leeway configured in netbox/venv/lib/python3.12/site-packages/social_core/backends/open_id_connect.py:

    JWT_LEEWAY: float = 1.0  # seconds

Which caused social-core to bail out with:

<class 'social_core.exceptions.AuthTokenError'>

Token error: The token is not yet valid (iat)

Took me some time to hunt down the problem even though the message was actually pretty self explanatory. At least in hindsight.

Compatibility Notes

Token signing (ES256): Kanidm defaults to ES256 (ECDSA) for JWT token signatures. The social-core OIDC backend defaults to only allowing RS256. Since social-core uses PyJWT I needed to manually add ES256 to the allowed algorithms in your netbox config:

SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']

PKCE: Kanidm requires PKCE (S256) by default. The OpenIdConnectAuth backend in social-core does not implement PKCE - it extends BaseOAuth2, not BaseOAuth2PKCE, so it never sends code_challenge or code_challenge_method parameters. Kanidm will reject the authorization request with No PKCE code challenge was provided with client in enforced PKCE mode. PKCE enforcement needed to be disabled for this client:

kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin

Step 1: Create the OAuth2 Client in Kanidm

From here on I suppose netbox is up and running at https://netbox.example.com while Kanidm is reachable at https://kanidm.example.com.

Create the OAuth2 confidential client:

kanidm system oauth2 create netbox "NetBox" "https://netbox.example.com" --name idm_admin

Add the redirect URI: The URI MUST match exactly what python-social-auth expects. The generic OIDC backend name is oidc, so the callback path is /oauth/complete/oidc/. If this doesn’t match exactly - wrong slash, wrong scheme (http vs https), wrong FQDN - you’ll get an error at Kanidm or be redirected back to netbox without being logged in.

kanidm system oauth2 add-redirect-url netbox "https://netbox.example.com/oauth/complete/oidc/" --name idm_admin

Disable PKCE:

kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin

Use short usernames (sends user instead of user@kanidm.example.com):

kanidm system oauth2 prefer-short-username netbox --name idm_admin

Step 2 : Creat a Group and Scope Mapping

Kanidm uses scope maps to control which groups can access which applications and what claims they receive.

kanidm group create netbox_users --name idm_admin
kanidm system oauth2 update-scope-map netbox netbox_users openid email profile --name idm_admin
kanidm group add-members netbox_users foobar --name idm_admin

Step 3: Retrieve the Client Secret

kandim automatically creates the secret, so we need to fetch it:

kanidm system oauth2 show-basic-secret netbox --name idm_admin

Note it down for later, treat it like a password.

Step 4: Verify the OIDC Discovery Endpoint

Before touching netbox’s config confirm the OIDC discovery endpoint is served correctly:

curl -sk https://kanidm.example.com/oauth2/openid/netbox/.well-known/openid-configuration | jq .

You should see a JSON document with authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri and the supported scopes/claims. If this doesn’t return valid JSON there’s something wrong with the Kanidm deployment.

Step 5: Update the netbox Configuration

The following configurations can be removed:

SOCIAL_AUTH_KEYCLOAK_*
LOGOUT_REDIRECT_URL

and add the following configuration:

# --- Kanidm OIDC Configuration ---
REMOTE_AUTH_ENABLED = True
REMOTE_AUTH_BACKEND = 'social_core.backends.open_id_connect.OpenIdConnectAuth'
REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = [ ]
REMOTE_AUTH_DEFAULT_PERMISSIONS = {}

# OIDC settings - the generic backend auto-discovers endpoints from OIDC_ENDPOINT
SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = 'https://kanidm.example.com/oauth2/openid/netbox'
SOCIAL_AUTH_OIDC_KEY = 'netbox'
SOCIAL_AUTH_OIDC_SECRET = '<secret from step 3>'

# CRITICAL: Kanidm uses ES256 by default, but social-core only allows RS256 out of the box.
# Add ES256 to the allowed algorithms so PyJWT can validate the tokens.
SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']

# Force HTTPS redirects (you're behind a reverse proxy)
SOCIAL_AUTH_REDIRECT_IS_HTTPS = True

# TLS verification — this should be True in production
# If Kanidm uses a private CA, install the CA cert in NetBox's trust store instead
SOCIAL_AUTH_VERIFY_SSL = True

Important Configuration Notes

SOCIAL_AUTH_OIDC_OIDC_ENDPOINT — Note the double OIDC_OIDC in the name. This is not a typo. The setting prefix is SOCIAL_AUTH_OIDC_ (from the backend name “oidc”) and the setting itself is OIDC_ENDPOINT. The backend appends /.well-known/openid-configuration to this value automatically.

LOGOUT_REDIRECT_URL — If you had this pointed at Keycloak’s logout endpoint, remove it. Kanidm doesn’t support OIDC front-channel or back-channel logout. Either remove the setting entirely (NetBox will redirect to its own login page) or set it to your NetBox URL. Note that logging out of NetBox won’t end the Kanidm session — users clicking the OIDC button again will be logged back in without re-authenticating.

Step 6: Handle TLS Trust (Private CA)

If Kanidm uses an internal CA, netbox needs to trust it for the OIDC flow (discovery, token exchange, userinfo).

Option A: System trust store

# Linux
cp /path/to/your-ca.pem /usr/local/share/ca-certificates/your-ca.crt
update-ca-certificates

# OpenBSD
cp /path/to/your-ca.pem /etc/ssl/certs/
cat /path/to/your-ca.pem >> /etc/ssl/cert.pem

Option B: Per-request CA path

# In NetBox configuration.py
SOCIAL_AUTH_REQUESTS_EXTRA_ARGUMENTS = {
    'verify': '/etc/ssl/certs/your-ca.pem'
}

Step 7: Restart NetBox and Test

# systemd
sudo systemctl restart netbox netbox-rq

# OpenBSD
doas rcctl restart netbox
  1. Navigate to your NetBox login page
  2. You should see an OpenID Connect link/button below the standard login form
  3. Click it — you should be redirected to Kanidm’s login page
  4. Authenticate with your Kanidm credentials
  5. On first login, Kanidm will show a consent prompt (unless you’ve disabled it)
  6. You should be redirected back to NetBox, logged in

Troubleshooting

Clock Skew: “Token is not yet valid” (iat or nbf)

Symptoms: social_core.exceptions.AuthTokenError: Token error: The token is not yet valid (iat) or the same error with (nbf).

Root cause: The iat (issued-at) and nbf (not-before) claims in the JWT are timestamps set by Kanidm’s clock. PyJWT validates these against the local system clock. Even 1-2 seconds of drift will cause failures because PyJWT’s tolerance is minimal (social-core 4.8.1 sets a 1-second leeway by default).

This error means the netbox host’s clock is behind Kanidm’s clock — the token appears to be issued in the future.

Diagnosis:

# Compare clocks — run from the netbox host
curl -ksI https://kanidm.example.com/status | grep -i date && date -u

If the timestamps differ by more than 1 second, you have clock skew. But be aware that HTTP Date headers only have second precision — for a more precise measurement:

python3 -c "
import requests, time
r = requests.head('https://kanidm.example.com/status', verify=False)
from email.utils import parsedate_to_datetime
server_time = parsedate_to_datetime(r.headers['date']).timestamp()
local_time = time.time()
print(f'Kanidm: {server_time}')
print(f'Local:  {local_time}')
print(f'Drift:  {server_time - local_time:.3f}s')
"

Fix: Sync NTP on both hosts. The fix depends on your OS:

# Linux (systemd/chrony)
timedatectl set-ntp true
chronyc makestep

# OpenBSD
ntpctl -s all  # check status — look for "clock synced"
# If "clock unsynced", restart ntpd:
doas rcctl stop ntpd
doas rcctl start ntpd
# Wait for "clock synced" to appear in ntpctl -s all

VM gotcha: If either host is a VM, the guest clock can drift from the hypervisor even when NTP looks healthy on the hypervisor host. Check ntpctl -s all (OpenBSD) or chronyc tracking (Linux) inside the VM, not just on the hypervisor. I hit this exact issue — the Proxmox host showed perfect NTP sync, but the OpenBSD VM guest was 1.8 seconds behind with clock unsynced.

Temporary workaround (while fixing NTP):

# In NetBox configuration.py — REMOVE this once clocks are synced
SOCIAL_AUTH_OIDC_JWT_DECODE_OPTIONS = {
    'verify_iat': False,
    'verify_nbf': False,
}

“The specified alg value is not allowed”

Cause: Kanidm signs tokens with ES256 by default, but social-core only allows RS256 unless you override it.

Fix: Add ES256 to the allowed algorithms in your NetBox config:

SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']

Do not downgrade Kanidm to legacy crypto (warning-enable-legacy-crypto) — PyJWT supports ES256 natively.

“No PKCE code challenge was provided” (Kanidm error)

Cause: Kanidm enforces PKCE by default, but the social-core OpenIdConnectAuth backend doesn’t implement PKCE.

Fix: Disable PKCE enforcement for this client in Kanidm:

kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin

This is acceptable for confidential clients (server-side apps with a client secret).

“Redirect URI mismatch” or “InvalidState” error at Kanidm

The redirect URI registered in Kanidm must match exactly. Verify with:

kanidm system oauth2 get netbox --name idm_admin

Check oauth2_rs_origin — it should include https://netbox.example.com/oauth/complete/oidc/ with the trailing slash. Common issues: missing slash, http vs https, wrong FQDN.

Redirected to NetBox but not logged in (no error page)

Check NetBox logs for the specific error:

# systemd
journalctl -u netbox -f

# OpenBSD
tail -f /var/log/netbox/netbox.log

Common causes:

  • SSL verification failure: netbox can’t validate Kanidm’s TLS cert. Fix the CA trust (Step 6).
  • Missing cryptography package: PyJWT needs cryptography for ES256. Check: pip show cryptography

Username format: user@domain instead of user

Kanidm sends the full SPN (Security Principal Name) as preferred_username by default — e.g., user@kanidm.example.com instead of just user.

Fix: Configure Kanidm to use short usernames:

kanidm system oauth2 prefer-short-username netbox --name idm_admin

Important: This only affects new tokens. If a user already logged in before this was set, their netbox account was created with the long username. You can rename it in the Django shell:

# cd /path/to/netbox && ./venv/bin/python3 manage.py nbshell
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(username='user@kanidm.example.com')
u.username = 'user'
u.save()

Existing Keycloak users can’t log in (account binding)

If users previously logged in via Keycloak, their UserSocialAuth records are associated with the keycloak backend. The new oidc backend creates separate associations. Options:

  1. Clean approach: Delete old social auth associations and have users re-link

    # Django shell
    from social_django.models import UserSocialAuth
    UserSocialAuth.objects.filter(provider='keycloak').delete()
    
  2. Associate by email: Add social_core.pipeline.social_auth.associate_by_email to the auth pipeline so existing users get matched by email address:

    SOCIAL_AUTH_PIPELINE = (
        'social_core.pipeline.social_auth.social_details',
        'social_core.pipeline.social_auth.social_uid',
        'social_core.pipeline.social_auth.social_user',
        'social_core.pipeline.user.get_username',
        'social_core.pipeline.social_auth.associate_by_email',
        'social_core.pipeline.user.create_user',
        'social_core.pipeline.social_auth.associate_user',
        'social_core.pipeline.social_auth.load_extra_data',
        'social_core.pipeline.user.user_details',
    )
    

Warning: associate_by_email trusts that the email from the identity provider is verified. This is generally safe with Kanidm (which verifies emails) on an internal network with a single IdP, but understand the implications.


Post-Migration Cleanup

Once you’ve confirmed everything works:

  1. Rotate/revoke the old Keycloak client secret for the netbox client in Keycloak
  2. Remove the Keycloak client if netbox was the last application using it
  3. Remove stale UserSocialAuth records for the keycloak provider (see above)
  4. Remove any LOGOUT_REDIRECT_URL pointing at Keycloak
  5. Disable the consent prompt in Kanidm if this is an internal deployment where consent is implied:
    kanidm system oauth2 disable-consent-prompt netbox
    

Quick Reference: Config Mapping

Keycloak Setting Kanidm Equivalent
SOCIAL_AUTH_KEYCLOAK_KEY SOCIAL_AUTH_OIDC_KEY
SOCIAL_AUTH_KEYCLOAK_SECRET SOCIAL_AUTH_OIDC_SECRET
SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL Auto-discovered via SOCIAL_AUTH_OIDC_OIDC_ENDPOINT
SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL Auto-discovered via SOCIAL_AUTH_OIDC_OIDC_ENDPOINT
SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY Not needed — JWKS auto-discovered
(not needed with Keycloak backend) SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']
Backend: keycloak Backend: oidc
Redirect path: /oauth/complete/keycloak/ Redirect path: /oauth/complete/oidc/

Kanidm Client Configuration (for reference)

The final Kanidm client configuration should look like this:

displayname: NetBox
name: netbox
oauth2_allow_insecure_client_disable_pkce: true
oauth2_jwt_legacy_crypto_enable: false
oauth2_prefer_short_username: true
oauth2_rs_origin: https://netbox.example.com/oauth/complete/oidc/
oauth2_rs_origin_landing: https://netbox.example.com/
oauth2_rs_scope_map: netbox_users@kanidm.example.com: {"email", "openid", "profile"}

Verify with: kanidm system oauth2 get netbox --name idm_admin

Kanidm OIDC Endpoints (for debugging)

All endpoints are auto-discovered via the OIDC discovery document, but for manual debugging:

Endpoint URL
Discovery https://kanidm.example.com/oauth2/openid/netbox/.well-known/openid-configuration
Authorization https://kanidm.example.com/ui/oauth2
Token https://kanidm.example.com/oauth2/token
Userinfo https://kanidm.example.com/oauth2/openid/netbox/userinfo
JWKS https://kanidm.example.com/oauth2/openid/netbox/public_key.jwk