# 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:

```python
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](performance-tuning.md) 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.
