WorkIdentity and cryptography
Making account disable revoke OIDC refresh tokens in a self-hosted identity provider
An identity provider codebase serving OIDC, LDAPS and RADIUS, and a mutation-tested fix for a familiar bug class: disabling a user left their refresh tokens working.
The problem
OAuth2 refresh tokens let a relying application obtain fresh access and ID tokens without the user present. That is why account shutdown has to reach it.
In the identity provider codebase described here, it did not. The refresh grant never checked the user the token belonged to, and an administrator disable deleted only the user’s web sessions. A disabled account’s refresh token therefore kept minting new tokens until it expired. Unredeemed authorization codes had the same gap.
The console showed the account as disabled while any application holding a refresh token kept working. The class of defect is familiar: the deprovisioning control is real, but covers only the credential types that existed when it was written.
What was built
One process serves three protocol faces from one store:
- OIDC / OAuth2: authorization code with PKCE, refresh with rotation, and a client-credentials grant issuing short-lived signed service tokens.
- LDAPS: a read-only bind-and-search directory for systems that cannot speak OIDC.
- RADIUS: authentication for network equipment that cannot speak OIDC, with per-client shared secrets.
Around those sit RBAC over users, groups and roles; argon2id passwords with lockout; WebAuthn/FIDO2, TOTP and recovery codes; server-side revocable sessions; an append-only audit log; and encrypted backup and restore.
The fix
Two changes, kept deliberately separate:
- The grants check the user. The authorization-code and refresh grants refuse with
invalid_grantwhen the user is missing, not active, or inside a lockout window, which is the same bar as web, LDAP and RADIUS login. Each refusal writes a deny row with its reason to the audit log. - Shutting an account revokes its grants. A revoke-by-user routine marks the user’s access and refresh tokens revoked and their pending codes consumed. It runs beside session revocation on administrator disable, lock and password reset (console and admin API), and on a user’s own password change.
Design decisions
- Refusal and revocation are different controls. The grant check refuses new tokens to a shut account, including refresh tokens issued before the fix existed. Revocation makes the shutdown permanent: re-enabling the account does not bring an old token back.
- An automatic lockout refuses but does not revoke. Otherwise anyone who knew a username could sign that user out of every application by typing wrong passwords. An administrative lock does revoke.
- A refused code is still spent. A code presented for a disabled user is consumed, so re-enabling the account cannot make it redeemable.
- Rows are flagged, not deleted, so the record still shows what was issued and that it was revoked.
- The same criteria on every face. The fix applies the same status-and-lockout criteria that login, LDAP and RADIUS already enforce, instead of a looser OIDC-specific rule.
Evidence and limits
The revocation suite is 16 named tests. Requests pass through the real application’s request handling, against a throwaway data directory. They cover the refresh grant for disabled, administratively locked, locked-out and deleted users, and the code grant for disabled and locked-out users; each of the seven administrator and self-service routes that shut an account; that other users’ tokens are untouched; and that an ordinary edit leaves tokens live.
Each test was shown to fail when it should. 22 single-edit mutants of the fix, plus the unfixed prior code, were run against the suite, and every mutant fails exactly the tests it targets. The unfixed code fails 13 of the 16. The other 3 are positive controls confirming that normal refresh, code redemption and non-shutting updates still work.
RADIUS, LDAPS and an OIDC authorization-code and PKCE round trip, including ID token signature verification, were exercised end to end with test clients.
Limits:
- The mutation testing covers the revocation change, not the whole codebase.
- The defect was found in passing during an adjacent review, not by a test. The suite exists so it cannot return silently.
Where it applies
- Account management evidence. For an organization preparing an authorization package against NIST 800-53 or CMS ARS, AC-2 covers disabling accounts. The sharper question is which credentials outlive the disable (refresh tokens, API keys, service tokens, sessions). A test that fails when revocation is removed is stronger evidence.
- Customer security questionnaires. A supplier answering a questionnaire, or aligning with IEC 62443 or NIST 800-171, is asked how departing users lose access. A failing-when-removed revocation test answers that in a form that can be demonstrated.
- Fractional security leadership. The method transfers to any identity stack: list every credential type issued, confirm deprovisioning reaches each, separate refusal from revocation, and keep a test that proves it.
Independent work by Wade Blackwell. Each write-up is written from scratch for this site, without client, employer or personal data. Names, hostnames, addresses and configuration detail are left out on purpose.