# External sign-in (SSO)

> Let a trusted identity provider sign users in to a Nodaro install with a signed JWT assertion or the OIDC and SAML options, and control how accounts are linked.

Source: https://nodaro.ai/docs/developers/sso

**External sign-in** (SSO) lets a trusted identity provider (IdP) sign users in to a Nodaro install. In the main integration, your IdP signs a short-lived JWT assertion, Nodaro verifies it, and the browser receives an ordinary Nodaro session. SSO is off until the operator of the install configures at least one provider.

The account a user ends up in is an ordinary account. SSO adds no special credential: it is only a way to start a session.

## Two integration styles

| Kind | For | How it works |
| --- | --- | --- |
| `assertion` | Issuers that do not speak OIDC or SAML, such as an embedding host that can only mint a short-lived signed token | Your IdP signs a JWT, and Nodaro verifies it and exchanges it for a session. This path works end to end. |
| `oidc`, `saml` | IdPs that speak standard OpenID Connect or SAML | The login page starts a standard sign-in with the install's own Supabase Auth, which verifies the IdP's answer. This path has limits; see [OIDC and SAML providers](#oidc-and-saml-providers). |

## Configure providers

Set `EXTERNAL_SSO_PROVIDERS` on the server to a JSON array of providers, inline or as `@/path/to/providers.json` (a leading `@` reads the file at that path). A malformed value stops the server at startup, so a typo can never silently drop a provider or half-configure sign-in.

```json
[
{
"id": "acme-chat",
"label": "Acme Chat",
"kind": "assertion",
"secret": "a-dedicated-32-character-hmac-key-for-nodaro-only",
"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"
}
]
```

| Field | Applies to | Meaning |
| --- | --- | --- |
| `id` | All | The provider's slug, used in the route `/v1/sso/:id` and stored on each linked account. 1 to 63 characters: a lower-case letter or a digit first, then lower-case letters, digits, `_` and `-`. No dots. Must be unique. |
| `label` | All | The provider's display name. |
| `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 secret, never the IdP's own session-signing secret, so a compromise of Nodaro cannot forge IdP sessions. |
| `audience` | `assertion`, required | The value the assertion's `aud` claim must equal. |
| `claimMap` | `assertion`, optional | Which claims carry the email, the verified flag and the subject. The default is `email`, `email_verified` and `sub`. |
| `initiateUrl` | `assertion`, optional | Where the SSO button sends the user. Without it, a click on the button answers `400 no_assertion`. |
| `initiateUrlByHost` | `assertion`, optional | A map from a bare, lower-case host name, without a port, to an address that replaces `initiateUrl` for that host. For a deployment that answers on several host names. |
| `maxLifetimeSeconds` | `assertion`, optional | The longest lifetime (`exp` minus `iat`) Nodaro accepts. The default is 300 seconds, and the maximum is 3,600. |
| `domain` | `oidc`, `saml` | Validated at startup, but not used. See [OIDC and SAML providers](#oidc-and-saml-providers). |
| `supabaseProvider` | `oidc` | Validated at startup, but not used. |

- `initiateUrl` forwards nothing to the IdP. If you want the user to land on a given page after sign-in, your IdP adds `&next=<path>` when it redirects back.
- The keys of `initiateUrlByHost` are validated at startup: a key with capitals or a port stops the server. The map is never published, and account linking does not depend on the host a user arrived on.

## The assertion contract

Your IdP mints a JWT and sends the browser to `GET /v1/sso/:provider?assertion=<jwt>`. Nodaro accepts the assertion only when every rule holds:

- **Algorithm.** The JWT is signed with `HS256` and the provider's `secret`.
- **Audience.** `aud` equals the provider's `audience`.
- **Expiry.** `exp` is present and not in the past, with a tolerance of 5 seconds for clock differences.
- **Lifetime.** `exp` minus `iat` is at most `maxLifetimeSeconds`, measured on Nodaro's clock. An `iat` more than 5 seconds in the future is clamped, and a missing `iat` counts as now.
- **Single use.** `jti` is present and unique. Nodaro remembers each `jti` until the assertion's own validity window has passed, and refuses it a second time.
- **Email.** The email claim is present.
- **Verified email.** The verified flag is `true` for an account to be created or linked. A missing flag counts as not verified.

A failing assertion answers `401` with `invalid_signature`, `invalid_claims`, `expired`, `too_long_lived`, `missing_jti`, `missing_email` or `assertion_replayed`. The endpoint accepts 20 requests per 60 seconds per IP address; beyond that it answers `429 rate_limit_exceeded` with a `Retry-After` header.

This IdP-side example mints a valid assertion with the `jose` library for Node.js:

```ts

const secret = new TextEncoder().encode(process.env.NODARO_SSO_SECRET)

const assertion = await new SignJWT({ email: user.email, email_verified: true })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setAudience("nodaro")
.setIssuedAt()
.setExpirationTime("2m")
.setJti(randomUUID())
.sign(secret)

const url = new URL("https://nodaro.example.com/v1/sso/acme-chat")
url.searchParams.set("assertion", assertion)
url.searchParams.set("next", "/projects")
res.redirect(url.toString())
```

## The sign-in flow

1. The user clicks the SSO button on the Nodaro login page, which calls `GET /v1/sso/:provider` without an assertion.
2. Nodaro redirects (`302`) to the provider's `initiateUrl`.
3. The IdP authenticates the user and sends the browser back to `GET /v1/sso/:provider?assertion=<jwt>`, optionally with `&next=<path>`.
4. Nodaro verifies the assertion, refuses replays and applies the [account-linking rules](#how-accounts-are-linked).
5. Nodaro redirects to `/sso?sso_token=<one-time token>`. That page exchanges the one-time token for a session.
6. The user lands on `/projects`, or on the `next` path.

An IdP, such as an embedding host, can also start at step 3: it redirects the browser to the exchange endpoint with a fresh assertion. The login page's own `?redirect=` parameter is not carried through the SSO redirects; only a `next` added by the IdP is.

## Show the SSO button on the login page

The login page shows an SSO button when both conditions hold:

- The deployment's surface profile lists `sso` in `auth.methods` and sets `auth.ssoLabel`. A profile without `ssoLabel` drops `sso` from the methods. The button text is the `ssoLabel`.
- At least one provider is configured. The page checks `GET /v1/sso/providers`.

Surface profiles are a Business and Cloud edition feature. See [Editions and surface profiles](https://nodaro.ai/docs/self-hosting/editions-and-profiles).

A profile whose `auth.methods` lists **only** `sso` also turns on a server-side gate. Every signed-in account must have been created or linked through SSO, and any other session is refused on its first API call with `403 sso_required`. The one exception is the deployment's billing account, described below. Adding `email` to the methods turns the gate off for the whole install.

## How accounts are linked

When an assertion is valid, Nodaro finds or creates the account under rules built so that **an assertion can never take over an existing account that merely shares the email address**.

| Situation | Result |
| --- | --- |
| No account has the email, and the email is verified | A new account is created and linked to the provider. |
| No account has the email, and the email is not verified | Refused with `403 email_unverified`, so an unverified claim cannot squat a real address. |
| The account is already linked to this provider | The user is signed in. |
| The account is linked to a different provider | Refused with `403 account_linked_other_provider`, even when `EXTERNAL_SSO_LINK_EXISTING` is on. |
| A local account without SSO exists, `EXTERNAL_SSO_LINK_EXISTING` is on, 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 off or the email is not verified | Refused with `403 account_exists`. |

`EXTERNAL_SSO_LINK_EXISTING` is off by default, which is the takeover-safe setting. Only `true` or `1`, in any letter case, turns it on. Turn it on only when you trust your IdPs' verified-email claims enough to attach them to accounts that already exist.

The refusals `account_exists` and `email_unverified` use the same wording for every account, so the login form cannot be used to find out which address holds a special role. `account_exists` is also the answer when the address matches more than one account, when the lookup fails, and when two sign-ins race to create the same account.

### On a deployment with a billing account

A deployment can name one **billing account** that pays for every user of the install; see [External wallets](https://nodaro.ai/docs/developers/external-wallet). The rules for that account are stricter, because it holds the deployment's credits:

- **It links on its first verified sign-in,** whatever `EXTERNAL_SSO_LINK_EXISTING` says. An unverified assertion for it is refused with `email_unverified`, and an assertion from a different provider with `account_linked_other_provider`.
- **Every later sign-in is checked again.** The email must be verified, and the subject must be the one that first linked the account. Otherwise the sign-in is refused with `403 account_linked_other_subject`.
- **It keeps a password as a break-glass door,** for the day the IdP cannot assert it. On an SSO-only deployment, that form is at `/login?billing=1`, and it admits only the billing account.
- **It cannot be banned or deleted** by an administrator of the install (`403 payer_account_protected`), so an admin role handed out by the deployment's own IdP cannot lock the install out of its credits.

If the IdP's subject for the billing account ever changes, its SSO sign-in keeps failing with `account_linked_other_subject`. The account can still sign in with its password.

The addresses of the platform operators (`PLATFORM_OPERATOR_EMAILS`, or `PLATFORM_OWNER_EMAIL` when that is empty) are never newly linked on a deployment with a billing account. They are refused with `account_exists`, which keeps the operator accounts out of SSO. An operator account that is already linked keeps signing in.

## OIDC and SAML providers

The `oidc` and `saml` kinds hand the sign-in to the install's own Supabase Auth, which verifies the IdP's answer. Both have limits:

- **`oidc`:** the login page passes the provider's `id` as the OAuth provider name. So the `id` must itself be the name of an OAuth provider configured in the install's Supabase Auth, such as `keycloak` or `azure`.
- **`saml`:** the login page passes the provider's `id` as the SAML domain. An `id` cannot contain a dot, so a domain such as `acme.com` cannot be expressed, and `saml` does not work end to end.
- An assertion sent to an `oidc` or `saml` provider is refused with `400 not_assertion_provider`.

## Endpoints

Both endpoints are public and need no token. They are the only routes under `/v1/sso/`.

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/v1/sso/providers` | Lists the providers for the login page, with `id`, `label` and `kind` only, never a secret. Returns an empty list when SSO is off. |
| `GET` | `/v1/sso/:provider` | The exchange endpoint. It is a browser redirect endpoint, not a JSON API. |

| Status | Code | When |
| --- | --- | --- |
| `400` | `no_assertion` | The button was clicked, and the provider has no `initiateUrl`. |
| `400` | `not_assertion_provider` | An assertion was sent to an `oidc` or `saml` provider. |
| `401` | `invalid_signature`, `invalid_claims`, `expired`, `too_long_lived`, `missing_jti`, `missing_email`, `assertion_replayed` | The assertion broke a rule of the contract. |
| `403` | `email_unverified`, `account_exists`, `account_linked_other_provider`, `account_linked_other_subject` | The account-linking rules refused the sign-in. |
| `403` | `sso_required` | An SSO-only deployment refused a session that did not come through SSO. |
| `404` | `unknown_provider` | No provider has that `id`, or SSO is off. |
| `429` | `rate_limit_exceeded` | Too many requests from one IP address. |

## Security notes

- **Use a dedicated secret.** The `secret` is a verification key for Nodaro only, separate from the IdP's own session secret.
- **Assertions work once.** The `jti` check refuses a submitted assertion a second time, and the lifetime cap keeps the window short.
- **Redirects stay on the install.** `next` is honored only as a relative path on the same install, starting with `/` but not `//`. Anything else lands on `/projects`.
- **No secret leaves the server.** `GET /v1/sso/providers` returns only `id`, `label` and `kind`. The assertion and the one-time token are removed from request logs.
- **The exchange is rate limited** per IP address.

## Frequently asked questions

### Is SSO on by default in Nodaro?

No. Until EXTERNAL_SSO_PROVIDERS names at least one provider, the login page shows no SSO button, GET /v1/sso/providers returns an empty list, and every provider route answers 404.

### How must the SSO assertion be signed?

With HS256 and a secret that you configure for the provider and use for nothing else. The assertion must also carry aud, exp, a unique jti and the email, and Nodaro accepts each assertion only once.

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

Not by default. An existing local account is linked only when EXTERNAL_SSO_LINK_EXISTING is true and the assertion says the email is verified. An account already linked to a different provider is never linked again.

### Is an SSO user a special kind of Nodaro account?

No. After the exchange, the user has an ordinary session. SSO is only a way to start that session.

### Does Nodaro support OIDC and SAML identity providers?

Partly. The oidc and saml kinds hand the sign-in to the install's own authentication service, with limits described on this page. The assertion exchange is the path that works end to end.
