# Single sign-on (https://docs.vitrinka.ai/operator/sso)



## Configure it [#configure]

**Settings → Organization → SSO.** Enter your issuer, client id and secret, and map your IdP's groups to roles. Two things help before you commit: **Check health** probes your issuer, and **test sign-in** shows exactly which claims arrive.

* **Entra ID, Keycloak, Okta** — all three are in use, but no per-provider walkthrough is written yet — ask, and you will get the exact issuer, scopes and claim mapping for yours. A provider on your internal network needs the operator first — [below](#private).
* **Group-to-role mapping** — reapplied at every login, so revoking a group in the directory revokes access here.
* **Default workspaces** — a plain organization member reaches only the workspaces they are explicitly a member of, so pick the shared workspaces every person who signs in through SSO for the first time should join. Each first sign-in seats them as a member there (audited as `member.join`); a role someone already holds in one of those workspaces is kept, never lowered. Owners and admins reach every workspace already and are not seated by this.

## An identity provider on your own network [#private]

By default the SSO client reaches identity providers on public addresses only: discovery, the key set, the token exchange and **Check health** refuse loopback, private and link-local targets, so no organization can probe your network through its connection. An internal Keycloak or ADFS — and every identity provider of an air-gapped install — takes two steps.

1. **The operator lists the provider's address** in `VITRINKA_SSO_PRIVATE_NETWORKS` and restarts: comma-separated CIDRs, where a bare IP counts as its `/32` or `/128`. List the provider's own address rather than a subnet. Loopback is reachable only when listed. Link-local, unspecified, multicast and broadcast addresses never are — an entry overlapping them refuses the boot — and known cloud-metadata addresses stay closed even inside a listed range. `AIR_GAP=1` opens nothing by itself and takes public addresses away: the listed internal ranges are then all SSO reaches, and an entry outside loopback, RFC 1918, ULA, CGNAT or class E refuses the boot. A provider whose certificate comes from your own CA also needs `VITRINKA_SSO_CA`: a PEM file path or the PEM text, parsed at boot and trusted beside the system roots by the SSO client alone.
2. **Deployment authority saves the connection.** Only a deployment-managed connection reaches a listed range: one saved in **Settings → Organization → SSO** by a platform admin, or an admin of the `VITRINKA_OPS_ORG` organization, who is also an admin of that connection's organization ([the first admin](/operator/install#admin)). Any save by such an admin marks the connection, even one that only changes groups or roles, and the issuer field's hint then says *deployment-managed*. An ordinary organization admin who saves the issuer, client ID or client secret makes it public-only again, so rotating the secret of an internal provider takes deployment authority too; deleting the connection clears the mark.

```bash
VITRINKA_SSO_PRIVATE_NETWORKS=10.20.30.40/32
VITRINKA_SSO_CA=/etc/vitrinka/sso-ca.pem
```

Until both are done, **Check health** names what is missing and who has to act. On an air-gapped install a connection nobody with deployment authority saved reaches nothing, and a public issuer is refused — **Check health** names `AIR_GAP` and the fix. After an upgrade, a connection that existed before stays public-only until deployment authority saves it once.

There is no cross-organization door: a platform admin marks connections only in organizations they administer, and `/admin` lists no SSO connections. The usual shape is one organization whose admins are the operators — name it in `VITRINKA_OPS_ORG`, or make the operator accounts platform admins.

<Callout type="warn">
  The mark vouches for the issuer that was saved, and trust then goes to whoever runs that issuer: its DNS picks the address, its discovery document picks the key-set and token endpoints, and its redirects are followed — anywhere inside the listed ranges. Save only connections to an identity provider you run or trust, and keep the list to that provider's own address. Revoking someone's platform admin, or their seat in the `VITRINKA_OPS_ORG` organization, does not clear marks they already set.
</Callout>

## Forced SSO [#forced]

Turn it on to require SSO for your organization's people. Who exactly that binds depends on the deployment:

* **Members of your organization** (the default, `VITRINKA_SSO_DOMAINS` off — the hosted service and any self-host without domain claims). The sign-in page keeps its password form for everyone; the check runs *after* a credential is proven, at the completion step every local path funnels through (password, email code, reset link, passkey, GitHub). A member of the forcing organization is refused there with a page that routes to your SSO, and the refusal is audit-logged. Anyone who is not a member — a user of another organization, a brand-new sign-up — signs in normally. Deciding post-credential is deliberate: the shared page never reveals who belongs to your organization.
* **Your claimed email domains** when `VITRINKA_SSO_DOMAINS=on` ([below](#domains)): those addresses are refused up front, on the page and at every local POST.
* **The whole deployment** when `VITRINKA_SSO_DOMAINS=on` and yours is the only enabled connection — the enterprise self-host shape, where you own the deployment. The lock lasts only while it is the only one: once any organization enables a second connection, it narrows to your claimed domains, so claim yours too.

With `VITRINKA_SSO_DOMAINS=on`, turning forced SSO on binds addresses on the sign-in page every organization shares, so it is a deployment-wide setting: it takes a platform admin, or an admin of the `VITRINKA_OPS_ORG` organization, who is also an admin of this one. An organization admin without that authority keeps a forced setting that is already on and can turn it off; while it is off, their switch reads *unavailable* with the reason.

Without domain claims the server refuses to save forced SSO while another organization's connection is enabled, and refuses to enable a second connection while one organization forces SSO — the setting is never accepted and then silently ignored.

There is a documented break-glass escape hatch at `/auth/login?local=1` for when your IdP is down; every use is audit-logged, and `VITRINKA_BREAK_GLASS` decides who may take it ([below](#breakglass)).

## Email domains [#domains]

Self-hosted deployments can set `VITRINKA_SSO_DOMAINS=on` to let each organization claim the email domains its identity provider owns. A claim is exact-match and exclusive across the deployment, shared mail providers cannot be claimed, and sign-ins for a claimed address route straight to that organization's connection. The hosted service keeps this off.

Because a claim routes addresses on the sign-in page every organization shares, adding one is a deployment-wide setting: it takes a platform admin, or an admin of the `VITRINKA_OPS_ORG` organization, who is also an admin of the claiming organization. An organization admin without that authority sees the reason in place of the field's hint and can still re-save or release the claims the organization holds. The refusal comes before any check against other organizations' claims, so it never reveals them. Claims saved before an upgrade to this rule stay as they were.

## MFA is verified, not assumed [#mfa]

MFA is enforced at your IdP — Entra conditional access, Keycloak required actions — and **checked** here. Turn on **Require MFA** and the server refuses any sign-in whose id\_token does not assert a multi-factor `amr` claim.

<Callout type="warn">
  The point is that a silently relaxed IdP policy fails **loudly** instead of quietly dropping the second factor. Entra v2.0 tokens need the `amr` optional claim configured on the app registration; v1.0 emits it by default.
</Callout>

## Break-glass is owner-only and passkey-first [#breakglass]

`/auth/login?local=1` arms a five-minute emergency grant for local sign-in. Two rules apply, in this order:

1. **Who may use it — `VITRINKA_BREAK_GLASS`** (`owners` by default): only an **owner** of the forcing organization may complete a break-glass sign-in; a member's attempt is refused as forced SSO and leaves an `auth.break_glass` audit row marked denied (`reason: not an owner`). `off` disables the door entirely — `?local=1` sets no grant and a forced refusal has no bypass. `all` lets anyone the posture binds use it (the pre-2026-09 behaviour). Like forced SSO itself, the rule is decided after the credential is proven.
2. **Passkey-first**: an account with a registered passkey must sign in with it. The password form only works for accounts that never enrolled one, and that weaker fallback writes its own audit row.

<Callout type="warn">
  Enrol a passkey for at least one admin **before** you need the emergency path.
</Callout>

## Session and header posture [#posture]

Every response carries a baseline header set — nosniff, `frame-ancestors 'self'`, Referrer-Policy, Permissions-Policy — and HSTS is added automatically when `BASE_URL` is https.

| Setting                   | Default                                        | Override                |
| ------------------------- | ---------------------------------------------- | ----------------------- |
| Absolute session lifetime | 720 hours; the enterprise bundle sets 12 hours | `VITRINKA_SESSION_TTL`  |
| Idle timeout              | off; the enterprise bundle sets 30 minutes     | `VITRINKA_SESSION_IDLE` |

<Callout type="warn">
  Both are enforced server-side. Combine them with forced SSO so your IdP's conditional-access policy gates every sign-in.
</Callout>
