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.

  1. The user is redirected to your identity provider.
  2. On success the app mints its own dmarc_session cookie — the same session a local password login produces.
  3. 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__UseForwardedHeaders set. The app builds its redirect URI from the scheme and host of the incoming request. Without a configured trust list it does not believe X-Forwarded-Proto, so it sends http://… to a provider expecting https://… 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_verified claim 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’s xms_edov optional claim is the fix; Auth__Oidc__TrustUnverifiedEmail is 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_admin password as the way back in — see below.

Roles

RoleCan do
agency_adminEverything, including users, clients, domains, mailboxes.
agency_analystRead and operate; not admin settings.
client_viewerRead-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.