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#
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.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.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 |
|
|
Issuer URL |
|
|
Client ID, client secret |
The secret is write-only; it is never shown again. It is sent as |
|
Scopes |
|
|
E-mail claim |
|
The claim matched to the account’s e-mail address |
Require |
on |
|
Extra authorization parameters |
|
Added to the request, e.g. |
Required claims |
|
Every listed claim must hold one of the values, e.g. |
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#
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
emailandacctto the ID token.
On the admin page, use:
Setting |
Value |
|---|---|
Issuer URL |
|
Client ID, client secret |
From the app registration |
E-mail claim |
|
Require |
Off. Entra ID does not send this claim, so every sign-in would be refused |
Required claims |
|
Claims that prove MFA |
|
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_verifiedoff, the address is trusted as the tenant reports it. That is safe only because the issuer,tidandacctrequirements 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 yourtid, but their address comes from their home organisation, which can set it to anything.acctis0for members and1for guests. To admit guests, do not drop the requirement; put a broker in front that verifies their addresses instead.Why
tidas 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_verifiedcan 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),
stateandnonce. 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: itsstateis remembered in the cache for the flow’s 10 minutes.Session cookie.
SESSION_COOKIE_SAMESITEdefaults toStrict. 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=Laxcookie. Over https it is named__Host-vl_sso_flow, which browsers only accept as Secure, host-only andPath=/, so a sibling subdomain cannot plant a flow of its own (login CSRF). Serve the instance over https; behind a proxy that does not forwardX-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/ssosends a signed-in user to the home page, like the password form does.Rate limit.
/user/login/ssoand its callback accept 30 requests per minute from one client address (the firstX-Forwarded-Foraddress, as for the API); more get429 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, orvl_sso_flowover 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/Hostor set the override.Turning it off. Untick Enable single sign-on. The password form never stops working.