DMARC Analyzer can authenticate operators against any OpenID Connect provider — Entra ID, Okta, Keycloak, Zitadel, Authentik, Google — while still deciding permissions itself.
How it fits together
The important design point: authentication is pluggable, authorisation never is.
- The user is redirected to your identity provider.
- On success the app mints its own
dmarc_sessioncookie — the same session a local password login produces. - Roles and client access come from DMARC Analyzer’s own records, not from the token.
So local passwords and SSO are interchangeable front doors by default, and you can enable SSO without migrating existing accounts. Your provider never controls who is an admin. Local login can be turned off entirely once SSO is working — see Requiring SSO for everyone below.
Per-provider guides
The steps below are generic and enough for any OpenID Connect provider. For one walked through screen by screen:
- Microsoft Entra ID — needs a client secret
- Google — a secret shown exactly once
- Keycloak — PKCE, with one default worth overriding
- Zitadel — PKCE, with no secret to store
- Authentik — two objects to create, and a secret by default
More to follow.
Register the application
In your identity provider, create a web client — confidential with a secret, or a public client using PKCE. The app always uses PKCE and only sends a client secret if you configure one, so either usually works.
Some providers insist on a secret and refuse the token exchange without one,
however PKCE is configured. Microsoft Entra ID is one: a redirect URI registered
under its Web platform is a confidential client by definition. A callback
failing with AADSTS7000218, or any “client_secret required” error, is the
provider telling you so.
- Redirect URI:
https://dmarc.example.com/api/v1/auth/oidc/callback - Scopes:
openid,profile,email - Note the issuer URL and client ID, plus the client secret if you created a confidential client.
The redirect URI ends in /callback — that is the path the app registers with the
handler. You may also see /api/v1/auth/oidc/complete in logs or a browser’s
address bar; that is an internal hop the app redirects itself to after the
provider has returned, and registering it as the redirect URI will fail with a
redirect_uri mismatch.
Configure the app
environment:
Auth__Oidc__Enabled: "true"
Auth__Oidc__Authority: "https://login.example.com"
Auth__Oidc__ClientId: "dmarc-analyzer"
Auth__Oidc__ClientSecret: "…" # omit entirely for a public PKCE client
Auth__Oidc__DisplayName: "Company SSO"
Auth__Oidc__AutoProvision: "true"
Auth__Oidc__DefaultRole: "client_viewer"
Restart the API and the login page gains a button labelled with your
DisplayName. Full option list: configuration
reference.
Behind a reverse proxy, SSO needs
Network__UseForwardedHeadersset. The app builds its redirect URI from the scheme and host of the incoming request. Without a configured trust list it does not believeX-Forwarded-Proto, so it sendshttp://…to a provider expectinghttps://…and the login is rejected. This is the one place where that setting is required rather than merely advisable — see running behind a reverse proxy.
The first sign-in
What happens the first time someone arrives from your provider depends on one setting, and the two paths suit different situations.
AutoProvision=false (the default) refuses anyone with no existing account,
rather than letting them in at some default role. So create the accounts first,
in Users, leaving the password
empty — that stores no password at all, so the account opens only by SSO. Their
first login attaches to it by verified email. This is the safer setting for an
agency install, and it is how you pick each person’s role up front instead of
promoting them afterwards.
AutoProvision=true creates an account on the spot at
Auth__Oidc__DefaultRole, which defaults to client_viewer — the least
privileged role, with no client access until an admin grants it. Convenient for a
first test; worth turning off once you are done.
Either way:
- An SSO identity links to a local user by verified email. Someone who already has a local account at that address gets it attached rather than duplicated, and an unverified address is refused, so it cannot be used to take over an account.
- Some providers assert nothing either way — Microsoft Entra ID sends no
email_verifiedclaim at all. That silence is refused too, since an address nobody vouched for should not open somebody else’s account, and it has its own message so you can tell it apart from a genuine “not verified”. Entra’sxms_edovoptional claim is the fix;Auth__Oidc__TrustUnverifiedEmailis the escape hatch for providers that cannot be made to answer. (0.9.0 or newer.) - Returning users are matched on issuer and subject, not email, so a later email change at the provider does not strand the account or create a second one.
- Keep one local
agency_adminpassword as the way back in — see below.
Roles
| Role | Can do |
|---|---|
agency_admin | Everything, including users, clients, domains, mailboxes. |
agency_analyst | Read and operate; not admin settings. |
client_viewer | Read-only, limited to explicitly granted clients. |
Enforcement is deny-by-default, and a request for another tenant’s data returns 404 rather than 403 — deliberately, so the API never reveals that a resource exists.
Requiring SSO for everyone
environment:
Auth__Oidc__DisableLocalLogin: "true"
Turns off password sign-in (/api/v1/auth/login refuses with 403) and has the
login page skip straight to your provider instead of showing a form with nothing
usable on it.
Registration is unaffected — it already refuses itself once the first account
exists, so bootstrapping a fresh instance still works locally. The intended order
is: bootstrap the first admin locally (or via AutoProvision), confirm SSO works,
then turn this on. Setting it without Auth__Oidc__Enabled is refused at
startup, since that combination would leave no way to sign in at all.
Keep a local admin
Leave at least one local password account with agency_admin, unless you’ve
deliberately turned on DisableLocalLogin above. If your provider or its
configuration breaks, that local account is how you get back in.
Testing locally
The repository’s development compose file includes a Zitadel instance for
exercising the OIDC path end to end, with a walkthrough in
docs/ops/oidc-zitadel.md.
Against a local HTTP-only test provider you’ll also need
Auth__Oidc__RequireHttpsMetadata: "false" — never in production.