The OIDC auth module as a droppable busbar plugin: a cdylib exporting the auth C ABI. Drop it in the plugins folder and add oidc to auth.chain with its settings nested under it.
| kind | alias | crate | busbar | license |
|---|---|---|---|---|
auth |
oidc |
busbar-auth-oidc-plugin |
1.6.0 (pinned in .busbar-ref) |
Apache-2.0 |
This plugin's version: 1.6.0. (Versioned in this repo — see Versioning below.)
The first-party, signed kind: auth plugin for
busbar: verifies a caller's identity by checking
a bearer JWT against an OpenID Connect identity provider's JWKS — real
signature verification (RS256 and ES256 on ring,
no jsonwebtoken/rsa), issuer/audience/expiry/not-before checks, and a
claim-to-role mapping — then hands busbar a Principal it can bind to
virtual keys and roles.
It is a cdylib exporting one door, busbar_plugin_door, on busbar's auth
memory ABI (via
busbar-contract),
and is loaded in-process by busbar as a signed plugin — dlopen'd, not
spawned as a separate process. A build can also link the same door in.
- Verifying who's calling: define an entry under
identity-providers:with itssettings:pointed at an IdP (Entra ID, Okta, Auth0, Keycloak, or any standards-compliant OIDC provider) and reference it by name fromauth.chain—identity-providers: { corp-oidc: { module: oidc, settings: {...} } }+auth: { chain: [keys, corp-oidc] }. Every request's bearer token is verified against the IdP's live JWKS — the plugin never trusts an unsigned claim. - Several identity providers in one chain (
auth.chainorauth.admin_auth): each token is routed by its issuer, as a multi-issuer OIDC resource server routes it. A bearer that is not a JWT, or a JWT whoseissis not this provider'sissuer(or names none), is not this provider's: it passes to the next auth in the chain, logged (the unverifiedissonly picks which provider judges the token, and no key is fetched for it). A JWT naming this provider'sissueris this provider's alone: verified in full (signature against its JWKS, the RS256/ES256 allow-list,aud,exp/nbfwith 60 s skew), and any failure refuses it, logged with the reason, and stops the chain; it never falls through to another provider. A token no provider claims is refused at the chain's end. - Mapping claims to busbar identity: the configured role claim (e.g.
groups) becomes thePrincipal's roles, which busbar's virtual-key and policy layer can then gate on — so an operator's existing IdP groups drive busbar access without a second identity system. - Outages are not bad credentials: a token is refused (401) only for
what is wrong with it — its signature, its claims, a kid absent from the
key set the IdP last answered. When the IdP cannot be had (its JWKS or
discovery fetch fails, or the rate limit is holding the retry off after a
failure, or the host clock is unreadable) a bearer verify FAILS with the
reason and a browser login answers the outage; at the token endpoint only
an RFC 6749 §5.2 credential error (a 400/401 carrying
error) declines a login, any other non-2xx is an outage.
This plugin's version is set in this repo. 1.6.0 puts it on the busbar
1.6 line, above every 1.5.5-era release of it (v1.0.x), so an operator's
version floor or rollback pin never downgrades onto one. Compatibility with
busbar is stated separately: requires busbar 1.6.0+ (it speaks busbar
1.6's memory ABI through busbar-contract, pinned in .busbar-ref; busbar
1.5.x speaks an older auth ABI and refuses to load it). Pin both versions
explicitly in production; do not assume they move together.
This repo is a same-repo, 2-crate Cargo workspace: auth-oidc/ (the
busbar-auth-oidc library — the real OIDC logic, no plugin ABI) and
auth-oidc-plugin/ (the busbar-auth-oidc-plugin cdylib adapter).
auth-oidc-plugin/src/lib.rs is a thin adapter: one line,
busbar_contract::export_door!(busbar_auth_oidc::door::door), which emits
the image's one symbol, busbar_plugin_door (the memory-ABI door). The
module and its settings parsing live in the logic crate.
All the actual OIDC logic — JWKS fetch/cache, JWT verification, claim
policy — lives in the busbar-auth-oidc library crate (auth-oidc/, a
same-repo sibling crate; see Dependencies below), so a
custom build can also link that logic statically instead of going
through the plugin ABI.
jwks_url in the config is optional: when omitted, it is resolved at
boot (the host's ready, after open and before any listener binds)
via the issuer's OIDC discovery document, so boot fails
loudly if discovery can't find it rather than deferring the failure to
the first request. The document is asked for again a day later and on
every refresh, the previous one serving meanwhile and if that fails.
| Setting | Required | Default | Notes |
|---|---|---|---|
issuer |
yes | — | The IdP's OIDC issuer URL, exact-matched against the JWT's iss claim: a token naming another issuer passes to the next auth in the chain, a token naming this one is verified in full. A blank value is refused at load. |
audience |
yes | — | The audience a bearer's aud claim must contain (busbar's registered client/resource identifier at the IdP); other audiences beside it are allowed (RFC 9068). A blank value is refused at load. |
jwks_url |
no | discovered from issuer |
The IdP's JWKS endpoint. Must be an https URL. Plain http is accepted only for a loopback host (127.0.0.0/8, ::1, localhost); it loads with a warning (also raised as the declared diagnostic OIDC_JWKS_PLAIN_HTTP, which the host audits), and since the key set is only ever fetched over https, every token is rejected until the URL is https. Anything else is refused at load. When omitted, resolved once at boot via OIDC discovery (<issuer>/.well-known/openid-configuration); a discovery failure refuses the boot. |
role_claim |
no | groups |
The claim mapped onto the resulting Principal's roles; set roles to use Entra app-roles instead. |
jwks_min_refetch_secs |
no | 60 |
JWKS refetch rate-limit (seconds) — the bound on how often a kid-rotation refetch can occur. |
jwks_ttl_secs |
no | 3600 |
JWKS cache TTL (seconds). |
ca_cert_pem |
no | — | An extra root CA (PEM) to trust for the JWKS/discovery HTTPS fetch, in addition to the built-in public (webpki) roots; the OS trust store is not consulted — for a private CA or a test fixture (this is exactly what tests/e2e.rs uses to trust its own self-signed local JWKS server). |
client_id |
no | audience |
The OAuth client_id presented on the browser-login authorize URL and token exchange; the login's id_token must name it in aud (and in azp when it carries one, OIDC Core §3.1.3.7). |
client_secret |
no | — | A SECRET REFERENCE (never the secret itself): the host resolves it and lends it at open, and the plugin sends it only in the token exchange's form body (sealed by the SDK, wiped after). Absent for a public client. |
scopes |
no | [] |
Extra OAuth scopes requested at browser login, on top of the always-sent openid. |
authorization_endpoint |
no | discovered from issuer |
The IdP authorize endpoint for browser login. When omitted, taken from the issuer's discovery document (fetched at boot when jwks_url is omitted too, else on the first browser login), which must name it over https (plain http only to loopback); absent and undiscoverable means browser login is refused (token verification is unaffected). A login the host lends no nonce is refused. |
token_endpoint |
no | discovered from issuer |
The IdP token endpoint for the browser-login code exchange. Discovered like authorization_endpoint when omitted. |
A discovered jwks_uri, token_endpoint or authorization_endpoint that
is not https (plain http only to loopback), or one the host's destination
guard refuses, is refused naming its host and the rule, and raised once
per instance as the declared diagnostic OIDC_IDP_DESTINATION_REFUSED,
which the host audits.
Unknown config fields are rejected (deny_unknown_fields) — a typo'd or
stray key fails loudly at boot instead of being silently ignored.
Every settings refusal is a settings[.<key>]: <reason> line (rendered by
the host under the provider's name), and never quotes a settings value.
Needs a Rust toolchain (rustup); rust-toolchain.toml pins
the version CI uses. busbar is a pinned git dependency (see
Dependencies below).
cargo build --release # cdylib: target/release/libbusbar_auth_oidc_plugin.{so,dylib}
cargo test # unit tests + the end-to-end loader/JWKS/JWT test (see tests/e2e.rs)
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --checkbusbar-auth-oidc (auth-oidc/) is a same-repo crate; auth-oidc-plugin
depends on it as a normal workspace path dependency (../auth-oidc).
The one busbar crate either crate
names is busbar-contract — the plugin contract, whose abi::sdk module
carries the export macros. busbar-plugin-loader is a dev-dependency only,
for the linked + dropped-in conformance test and the end-to-end test. Both
are git dependencies pinned to one busbar commit: the rev in every
Cargo.toml is field 1 of .busbar-ref, and CI's pin job refuses a
manifest that disagrees. No sibling checkout of busbar is needed to build
or test; the live end-to-end test (tests/e2e.rs) builds the real busbar
binary from a checkout named by BUSBAR_CHECKOUT (CI's e2e job provides
one at .busbar-ref).
Once built, the cdylib is packed and signed like any other busbar plugin
— see
docs/plugins.md
in busbar for the full reference. In short:
BUSBAR_SIGN_KEY=<signing key> busbar-plugin-pack pack \
--lib target/release/libbusbar_auth_oidc_plugin.so \
--name busbar-auth-oidc-plugin --alias oidc --kind auth \
--version 1.6.0 --publisher busbar \
--license Apache-2.0 \
--out busbar-auth-oidc-plugin-1.6.0-x86_64-linux.tar.gzFor local development without a signing key, busbar-plugin-pack pack --allow-unsigned produces a tarball busbar loads only under
plugins.trust.allow_unsigned: true.
Drop the resulting tarball into busbar's configured plugins.dir and
set:
identity-providers:
corp-oidc: # the NAME is the instance; `module:` is the plugin behind it
module: oidc
settings:
issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
audience: "<client-id>"
role_claim: groups
# max_admin_scope: # per-provider admin ceiling; OMITTED = the most restrictive
# (read-only). Never widen an external IdP to `full` casually.
auth:
chain: [keys, corp-oidc] # built-ins (`keys`, `admin-tokens`) are referenced bareClaim-to-role mapping is keyed by that same provider name:
auth:
role_bindings:
corp-oidc:
"<sso-group>": { group: engineering }— see docs/configuration.md
for the full identity-providers: / auth.chain config reference.
cargo test runs auth-oidc/'s hermetic unit tests (auth-oidc/src/tests.rs
and auth-oidc/src/tests/ — the JWT/JWKS logic, the settings parsing
open makes (tests/open_tests.rs: empty/malformed/missing-required-field/
unknown-field config, a non-https jwks_url), discovery, the caches and the
door, all without any network I/O) and auth-oidc-plugin's tests: the
conformance suite (auth-oidc-plugin/tests/conformance.rs, the linked and
the dropped-in door) and the end-to-end test in
auth-oidc-plugin/tests/e2e.rs. Both crates' tests run under a
workspace-wide cargo test.
The end-to-end test is NOT a stub: it stands up a genuine local HTTPS
JWKS server (a real self-signed certificate minted with rcgen, served
over a real rustls TLS listener on 127.0.0.1), mints a real
ES256-signed JWT with ring, and dlopens the actually-built
busbar-auth-oidc-plugin cdylib over busbar-plugin-loader's real
kind: auth C ABI seam — the same seam busbar's engine uses. It proves,
entirely offline (no external IdP, no Docker service):
- a genuine JWKS fetch over real TLS, trusted via the plugin's own
ca_cert_pemconfig field; - genuine ES256 signature verification (a token signed by the fixture's
key is accepted and its claims mapped to a
Principal; a token signed by a different key with the samekidis rejected); - the full claim →
Principalmapping (sub/preferred_username/name/the configured role claim) survives the round trip across the real C ABI, not just in-process Rust calls; - a load-time config error (empty config, or config missing a required
field) surfaces back across the ABI as a clean
Err, never a panic or a silently-succeeded load.
Build under cargo test --workspace-equivalent (i.e. a normal cargo build first, or just cargo test, which builds the cdylib as part of
the test run) so the e2e test finds the library; it self-skips with a
message if the cdylib isn't present locally, but hard-fails under CI
(CI env var set) instead of silently skipping — this is the only
over-the-ABI coverage of the kind: auth dlopen seam and must never
quietly vanish.
Licensed Apache-2.0 (LICENSE). Contributions welcome — see CONTRIBUTING.md. Governed by our Code of Conduct; security issues go through SECURITY.md, not public issues.