# Single sign-on

> Let a trusted identity provider sign users in to your self-hosted Nodaro. Configure EXTERNAL_SSO_PROVIDERS, choose the account-linking rules and allow SSO only.

Source: https://nodaro.ai/docs/self-hosting/sso

**Single sign-on (SSO)** lets a trusted external identity provider sign users in to your self-hosted Nodaro. You describe each provider in the `EXTERNAL_SSO_PROVIDERS` variable, and the login page shows an SSO button. The account a user ends up in is an ordinary user of your install: SSO is only a way to start a session. This page covers the operator's side; the protocol is in [Single sign-on for developers](https://nodaro.ai/docs/developers/sso).

## Which kind of provider to use

| Kind | Use it when | Status |
| --- | --- | --- |
| `assertion` | Your issuer cannot speak OpenID Connect or SAML but can sign a short-lived JWT, for example an app that embeds Nodaro. Nodaro verifies the token and exchanges it for a normal session. | Works end to end. |
| `oidc` | Your identity provider speaks OpenID Connect and is configured in your install's Supabase Auth. The browser does a normal Supabase sign-in redirect, and Supabase verifies the result. | The provider's `id` must be the name of the OAuth provider in your Supabase Auth, such as `keycloak` or `azure`. |
| `saml` | — | Entries pass validation, but the login page cannot send a SAML domain, so they do not sign anyone in. Use `assertion` or `oidc`. |

SSO is off by default. With `EXTERNAL_SSO_PROVIDERS` unset, the login page shows no SSO button, `GET /v1/sso/providers` returns an empty list, and every provider route answers `404 unknown_provider`.

## Configure the providers

Set `EXTERNAL_SSO_PROVIDERS` to a JSON array, inline or as `@/path/to/providers.json` to read a file.

```json
[
{
"id": "librechat",
"label": "LibreChat",
"kind": "assertion",
"secret": "a-dedicated-32+char-hmac-key-not-the-idp-session-secret",
"audience": "nodaro",
"claimMap": { "email": "email", "emailVerified": "email_verified", "subject": "sub" },
"initiateUrl": "https://chat.example.com/oauth/nodaro",
"maxLifetimeSeconds": 300
},
{
"id": "keycloak",
"label": "Acme (Keycloak)",
"kind": "oidc",
"supabaseProvider": "keycloak"
}
]
```

A malformed value **stops the boot**. A typo in an entry that holds a secret must never drop a provider silently, which would look like SSO failing with `404`, or half-configure sign-in.

On the compose stack, add `EXTERNAL_SSO_PROVIDERS` and `EXTERNAL_SSO_LINK_EXISTING` under `environment:` of the `nodaro` service, because the compose file does not pass them from `.env`. To use a file, mount it into the container and give its path inside the container.

### Provider fields

| Field | Applies to | What it does |
| --- | --- | --- |
| `id` | All | A URL-safe name: a lowercase letter or digit first, then lowercase letters, digits, `_` or `-`, 1 to 63 characters in all, no dots. It appears in the route `/v1/sso/<id>` and is stored on the user. Must be unique. |
| `label` | All | The provider's name. It appears on the login button only when two or more providers exist and the surface profile sets no `auth.ssoLabel`. |
| `kind` | All | `assertion`, `oidc` or `saml`. |
| `secret` | `assertion`, required | The HS256 key that verifies the assertion's signature, at least 16 characters. Use a dedicated key, never the identity provider's own session secret, so that a leak on Nodaro's side cannot forge the provider's sessions. |
| `audience` | `assertion`, required | The value the assertion's `aud` claim must equal. |
| `claimMap` | `assertion` | Which claims carry the email, the verified flag and the subject. The default is `{ "email": "email", "emailVerified": "email_verified", "subject": "sub" }`. |
| `initiateUrl` | `assertion` | An absolute URL. When a user clicks the login button, Nodaro redirects there. The provider signs the user in and comes back with the assertion. Without it, a click answers `400 no_assertion`. |
| `initiateUrlByHost` | `assertion` | A map from a bare lowercase hostname, without a port, to an absolute URL. A click that arrives on that host goes there instead of `initiateUrl`. For installs that answer on several hostnames. A wrong key stops the boot, and the map is never published. |
| `maxLifetimeSeconds` | `assertion` | The longest lifetime an assertion may have, `exp` minus `iat`. The default is `300`, 5 minutes, and the maximum is `3600`. |
| `supabaseProvider` | `oidc` | Validated but not used: the login page passes the provider's `id` to Supabase. |
| `domain` | `oidc`, `saml` | Validated but not used. An `oidc` or `saml` entry needs `domain` or `supabaseProvider`. |

Only `id`, `label` and `kind` are ever published, by `GET /v1/sso/providers`. The secret never leaves the server.

## What Nodaro checks in an assertion

An `assertion` provider's identity server signs a JWT and sends the browser to `GET /v1/sso/<id>?assertion=<jwt>`. Nodaro accepts it only when all of these hold:

- It is signed with **HS256** and the provider's `secret`.
- **`aud`** equals the provider's `audience`.
- **`exp`** is present and not past, with 5 seconds of tolerance, and the lifetime is within `maxLifetimeSeconds`.
- **`jti`** is present and has never been used. Each assertion works once.
- The **email** claim is present. The **verified** claim must be `true` to create or link an account.

A failed check answers `401`. The route allows 20 requests per 60 seconds from one IP address, then answers `429`. The full contract, the error codes and the redirect flow are in [Single sign-on for developers](https://nodaro.ai/docs/developers/sso).

## How accounts are linked

When an assertion passes, Nodaro finds or creates the matching user. The rules make sure that an assertion can never take over an existing account only because it has the same email address.

| Situation | Result |
| --- | --- |
| No account has the email, and the email is verified | A new user is created and linked to the provider. |
| No account has the email, and it is not verified | Refused: `403 email_unverified`. |
| The account is already linked to this provider | Signed in. |
| The account is linked to a different provider | Refused: `403 account_linked_other_provider`, even with `EXTERNAL_SSO_LINK_EXISTING=true`. |
| A local account without SSO exists, `EXTERNAL_SSO_LINK_EXISTING=true`, and the email is verified | The account is linked to the provider and signed in. |
| A local account without SSO exists, and the flag is `false` or the email is not verified | Refused: `403 account_exists`. |

`EXTERNAL_SSO_LINK_EXISTING` defaults to `false`, which is safe against takeovers. Only `true` or `1`, in any letter case, enables it. Enable it only when you trust your providers' verified-email claims enough to attach them to existing accounts.

`account_exists` and `email_unverified` use the same wording for every account, so the login form cannot be used to probe which addresses exist. The server log records which account was targeted.

## The login button

The login page shows an SSO button when at least one provider is configured.

- **One provider:** the button reads **Single sign-on**, or the surface profile's `auth.ssoLabel`.
- **Several providers:** each button shows its provider's `label`, unless the surface profile sets `auth.ssoLabel`.

After sign-in, the user lands on `/projects`. The provider can send a different page by adding `&next=<path>` when it redirects back. Nodaro honors it only when it is a relative path on the same origin, starting with `/` but not `//`. Anything else goes to `/projects`. On the hosts listed in `CORS_ORIGIN`, the landing redirect stays relative, on the host the user arrived on.

## Allow sign-in through SSO only

On the Business edition, the surface profile can remove the other sign-in methods:

```bash
NODARO_SURFACE_PROFILE={"auth":{"methods":["sso"],"ssoLabel":"Sign in with Acme"}}
```

A list that names only `sso` also enables a rule on the server: every signed-in account must have been created or linked through SSO. Any other session is refused on its first API call with `403 sso_required`, so an account registered directly on the sign-in service cannot use the install. Adding `email` to the list disables the rule. See [Editions and surface profiles](https://nodaro.ai/docs/self-hosting/editions-and-profiles#sign-in-methods).

## Security summary

- **A dedicated secret** per provider, distinct from the provider's own session secret.
- **Single-use assertions**, with a short, server-enforced lifetime.
- **Same-origin redirects only** after sign-in.
- **No secret leaves the server.** Assertions and one-time tokens are removed from request logs.
- **Rate limits** on the exchange route, per IP address.
- **Ordinary sessions.** After the exchange, the user is a normal user with no special credential.

## Frequently asked questions

### Is single sign-on on by default?

No. With EXTERNAL_SSO_PROVIDERS unset, the login page shows no SSO button and every SSO route answers 404. Configure at least one provider to enable it.

### Which kind of provider should I configure?

Use assertion for an issuer that can mint a short-lived signed token, such as an app that embeds Nodaro. Use oidc for an identity provider that you configured in your install's Supabase Auth. Entries of kind saml pass validation but do not sign anyone in.

### Can SSO take over an existing account with the same email?

Not by default. EXTERNAL_SSO_LINK_EXISTING is false, so an assertion for an email that already has a local account is refused with account_exists. Set it to true only when you trust your providers' verified-email claims.

### How do I allow sign-in through SSO only?

On the Business edition, set the surface profile's auth.methods to ["sso"] with an auth.ssoLabel. Every account must then come through SSO, and any other session is refused with 403 sso_required.

### What happens if EXTERNAL_SSO_PROVIDERS contains a mistake?

The install refuses to boot. A malformed value never silently drops a provider or half-configures sign-in.
