Single sign-on#

Vulnerability-Lookup can offer a Sign in with … button on the login page that signs people in through an OpenID Connect provider: Keycloak, Microsoft Entra ID, Google, Okta, authentik, or anything else that publishes a discovery document. It is off by default, and the password form stays next to it.

/user/login → "Sign in with …" → /user/login/sso → provider (authorization code + PKCE)
            → /user/login/sso/callback → signed in, or on to the local 2FA pages

Who can sign in#

The provider only proves who someone is. A sign-in is accepted for an account that already exists on this instance and is active and confirmed. The match is on the ID token’s e-mail address, ignoring case, and it must match exactly one account. Nothing is created automatically. To give someone access, create their account under Admin → Users with the address the provider knows them by, or let them register where self-registration is open.

A sign-in is refused when:

  • the provider does not mark the address as verified (email_verified), unless that check is switched off;

  • a required claim does not hold one of its accepted values;

  • no active, confirmed account has that address, or more than one does.

The person signing in is told which of these it was, without the details; the website log has those.

The e-mail address is the only link between a provider identity and an account, so whoever can set an address that the provider presents as verified can sign in as the account with that address. Connect only providers where that is your own administrators, and narrow everything else with required claims (see the Entra ID guests and the broker notes below).

Two-factor authentication#

By default a single sign-on login goes through the same 2FA steps as a password login: the TOTP is asked if the account has one, and set up first if ENFORCE_2FA is on.

If the provider already enforces multi-factor authentication, list the claims that prove it under Claims that prove MFA, for example {"amr": ["mfa"]}. When any of them matches, the local TOTP is skipped for that login. Only do this for a claim the provider sets after MFA, and never for one users can edit at the provider (a self-service profile attribute, for example). Password logins are unaffected.

Setting it up#

  1. Register a client with the provider:

    • web application, authorization code flow; confidential (with a client secret), or public (no secret, PKCE only) where the provider allows it;

    • PKCE S256;

    • redirect URI https://<this instance>/user/login/sso/callback. The admin page shows the exact value.

    The ID token must carry an e-mail address; the default scopes (openid email profile) ask for it.

  2. Open Admin → Access → Single sign-on (/admin/sso/) and fill in the issuer, the client ID and the secret. Tick Enable single sign-on and save. Before saving, the page reads the issuer’s /.well-known/openid-configuration, so a wrong issuer is reported there instead of at the next login.

  3. Sign in from a private window. Keep an administrator session open until it works.

Setting

Default

Meaning

Enable

off

Shows the button and opens the routes. Also needs an issuer and a client ID

Button label

Sign in with SSO

Issuer URL

https:// (or localhost); must equal the issuer in the discovery document

Client ID, client secret

The secret is write-only; it is never shown again. It is sent as client_secret_post, or as client_secret_basic when the provider lists only that. Without one, the instance signs in as a public client (PKCE only, no client_secret sent)

Scopes

openid email profile

E-mail claim

email

The claim matched to the account’s e-mail address

Require email_verified

on

email_verified vouches for the email claim only: with another e-mail claim, that claim must also equal email

Extra authorization parameters

{}

Added to the request, e.g. {"prompt": "select_account"}, or {"kc_idp_hint": "…"} to send Keycloak straight to one upstream provider

Required claims

{}

Every listed claim must hold one of the values, e.g. {"groups": ["vl-users"]}

Claims that prove MFA

{}

Any match skips the local TOTP

Redirect URI override

Only when the instance cannot work out its own public URL (a proxy without forwarded headers)

A text setting left empty takes its default again.

The settings are stored in the app_setting table under the key sso.

Pinning settings in website.py#

Each setting has an OIDC_* key: OIDC_ENABLED, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SCOPES, OIDC_AUTH_PARAMS, OIDC_BUTTON_LABEL, OIDC_EMAIL_CLAIM, OIDC_REQUIRE_VERIFIED_EMAIL, OIDC_REQUIRED_CLAIMS, OIDC_MFA_CLAIMS and OIDC_REDIRECT_URI.

A key set in website.py overrides the stored value and shows read-only on the admin page. In OIDC_REQUIRED_CLAIMS and OIDC_MFA_CLAIMS, a single value instead of a list ({"groups": "vl-users"}) is taken as a one-item list and matched whole. A deployment that manages its configuration as files can pin all of them. Keeping the secret out of the database works too:

import os

OIDC_ISSUER = "https://idp.example.org/realms/example"
OIDC_CLIENT_ID = "vulnerability-lookup"
OIDC_CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]

Leave OIDC_ENABLED unset, and administrators can still switch single sign-on on and off from the page.

Pinned values must have the type of the setting: True/False for the two switches, a dict for the JSON settings, a string for the rest. A value of another type (for example OIDC_ENABLED = os.environ.get(...), which is a string) or an issuer that is not https switches single sign-on off, and the admin page says why.

Example: Microsoft Entra ID, directly#

  1. In the Entra admin center, register an application for this tenant only (single tenant).

    • Add a Web platform with the redirect URI shown on the admin page.

    • Create a client secret.

    • Under Token configuration, add the optional claims email and acct to the ID token.

  2. On the admin page, use:

Setting

Value

Issuer URL

https://login.microsoftonline.com/<tenant-id>/v2.0

Client ID, client secret

From the app registration

E-mail claim

email, or preferred_username if your users’ sign-in names are their e-mail addresses

Require email_verified

Off. Entra ID does not send this claim, so every sign-in would be refused

Required claims

{"tid": ["<tenant-id>"], "acct": [0]} (members of the tenant only, no guests)

Claims that prove MFA

{"tid": ["<tenant-id>"]} only if Conditional Access requires MFA for every sign-in to this application, with no exclusions (trusted locations, excluded users); otherwise leave it empty

Notes:

  • Use the tenant-specific issuer. The multi-tenant endpoints (common, organizations) publish an issuer with a {tenantid} placeholder. It matches no token, and the admin page refuses it.

  • Why the e-mail check has to be off. With email_verified off, the address is trusted as the tenant reports it. That is safe only because the issuer, tid and acct requirements limit sign-in to members of your own tenant, whose addresses only your administrators set. Never relax it for a tenant you do not control: an administrator there could give an account any address, including one of yours, and sign in as that person.

  • Why acct: 0. B2B guests also carry your tid, but their address comes from their home organisation, which can set it to anything. acct is 0 for members and 1 for guests. To admit guests, do not drop the requirement; put a broker in front that verifies their addresses instead.

  • Why tid as the MFA claim. An Entra ID token does not reliably say which factors were used. Naming the tenant there means “this tenant’s sign-in policy is trusted to have required MFA” for every member. A single exclusion in that policy lets a password-only sign-in skip the local TOTP too. Without such a policy, leave it empty and keep the local TOTP.

Example: Google#

Require the Google Workspace domain: Required claims {"hd": ["example.com"]}. Without it, anyone can create a personal Google account on a company address, verify it once, and keep it, with email_verified still true, after leaving the company. Personal accounts carry no hd claim, so the requirement refuses them.

Directly or through a broker#

This instance talks to one provider. Connecting an identity provider directly is the simplest setup when every user comes from it, for example one organisation and its own Entra ID tenant.

Put a broker in between when that does not fit. A broker is an identity server that federates several providers, such as Keycloak or authentik. It fits when:

  • users come from several organisations or tenants. The broker holds one upstream connection per organisation, and this instance trusts only the broker;

  • you want one place to enforce MFA and send an MFA claim this instance can check. For example, Keycloak can map the upstream provider’s alias into a claim. Pair it with {"kc_idp_hint": "<alias>"} under Extra authorization parameters to skip the broker’s own login page;

  • the provider cannot vouch for e-mail addresses, and the broker can. The broker can trust or verify them, so email_verified can stay on.

Since accounts are matched by e-mail address, every upstream behind the broker can sign in as any address it can present as verified. Make sure each upstream can only assert addresses of its own organisation: do not let the broker trust e-mail addresses from an upstream you do not control, or restrict each upstream to its own domains in the broker’s first-login flow. kc_idp_hint only routes the browser to an upstream, and anyone can remove it from the URL; it is not access control. A required claim naming the upstream does not help either when several upstreams are accepted.

The cost is one more system to run, and its availability now gates single sign-on. The password form still works without it.

How it works#

  • Protocol. Authorization code flow with PKCE (S256), state and nonce. The ID token’s signature is checked against the provider’s published keys, together with its issuer, audience, authorized party (azp), expiry and nonce. Up to 60 seconds of clock skew between this server and the provider is tolerated on the time claims. The discovery document and the keys are cached for an hour. When the provider rotates its key, the keys are fetched again once. On every sign-in the discovery document must name the configured issuer and https endpoints; redirects are not followed. A flow is redeemed once: its state is remembered in the cache for the flow’s 10 minutes.

  • Session cookie. SESSION_COOKIE_SAMESITE defaults to Strict. The browser returns from the provider through a cross-site redirect chain, and it does not send a Strict cookie on that chain. Three consequences:

    • the flow state lives in its own signed, short-lived, SameSite=Lax cookie. Over https it is named __Host-vl_sso_flow, which browsers only accept as Secure, host-only and Path=/, so a sibling subdomain cannot plant a flow of its own (login CSRF). Serve the instance over https; behind a proxy that does not forward X-Forwarded-Proto, an https redirect URI override is enough for the cookie to be __Host- too;

    • the callback answers with a small page that continues as a same-site navigation, not with a redirect. With a redirect, the landing page would load without the new session;

    • a failed callback writes nothing to the session, because the session it would write would replace the one the browser holds. It continues to /user/login/sso/failed, which shows the message. Someone already signed in who cancels at the provider, for example from a second tab, stays signed in.

  • Already signed in. /user/login/sso sends a signed-in user to the home page, like the password form does.

  • Rate limit. /user/login/sso and its callback accept 30 requests per minute from one client address (the first X-Forwarded-For address, as for the API); more get 429 Too Many Requests. Each callback can make a request to the provider’s token endpoint, and the limit keeps that from being driven in a loop.

  • Caching. Every login page reads whether the button is shown, so that part is cached like the site notice. It holds no secret. The full settings are read from the database per sign-in.

Troubleshooting#

The website.web.views.sso logger records why each sign-in was refused.

  • “No active account matches this identity”. No active, confirmed account has the provider’s address, or more than one does.

  • “The identity provider has not verified your e-mail address”. The ID token has no email_verified: true. Entra ID never sends it; see the Entra ID example above.

  • “Your account at the identity provider is not allowed to sign in here”. A required claim did not hold an accepted value.

  • “Single sign-on is not set up correctly on this server”. The token endpoint refused the code (often a wrong client secret), the ID token did not verify, or it lacks the e-mail claim.

  • “The single sign-on attempt expired or was interrupted”. More than 10 minutes at the provider, the browser came back without the flow cookie or with another state, or the same flow was used twice (a reload of the callback).

  • “Single sign-on was cancelled” or “The identity provider could not sign you in”. The provider returned an error instead of a code.

  • “Single sign-on is unavailable right now”. The discovery document could not be read from the server, or it does not name the configured issuer and https endpoints, or the settings could not be read from the database. The log says which. Check outbound access to the issuer.

  • Every sign-in ends with “expired or was interrupted” behind a cache. A cache in front of the instance must pass the flow cookie (__Host-vl_sso_flow, or vl_sso_flow over plain http) through to the callback. The Varnish example in Performance tuning keeps it.

  • The provider rejects the redirect URI. Compare it with the one on the admin page. Behind a reverse proxy, either forward X-Forwarded-Proto/Host or set the override.

  • Turning it off. Untick Enable single sign-on. The password form never stops working.