Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch

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.

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

KindForHow it works
assertionIssuers that do not speak OIDC or SAML, such as an embedding host that can only mint a short-lived signed tokenYour IdP signs a JWT, and Nodaro verifies it and exchanges it for a session. This path works end to end.
oidc, samlIdPs that speak standard OpenID Connect or SAMLThe 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.

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.

[
  {
    "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"
  }
]
FieldApplies toMeaning
idAllThe 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.
labelAllThe provider's display name.
kindAllassertion, oidc or saml.
secretassertion, requiredThe 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.
audienceassertion, requiredThe value the assertion's aud claim must equal.
claimMapassertion, optionalWhich claims carry the email, the verified flag and the subject. The default is email, email_verified and sub.
initiateUrlassertion, optionalWhere the SSO button sends the user. Without it, a click on the button answers 400 no_assertion.
initiateUrlByHostassertion, optionalA 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.
maxLifetimeSecondsassertion, optionalThe longest lifetime (exp minus iat) Nodaro accepts. The default is 300 seconds, and the maximum is 3,600.
domainoidc, samlValidated at startup, but not used. See OIDC and SAML providers.
supabaseProvideroidcValidated 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:

import { SignJWT } from "jose"
import { randomUUID } from "node:crypto"

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.
  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.

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.

SituationResult
No account has the email, and the email is verifiedA new account is created and linked to the provider.
No account has the email, and the email is not verifiedRefused with 403 email_unverified, so an unverified claim cannot squat a real address.
The account is already linked to this providerThe user is signed in.
The account is linked to a different providerRefused 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 verifiedThe 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 verifiedRefused 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. 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/.

MethodPathWhat it does
GET/v1/sso/providersLists 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/:providerThe exchange endpoint. It is a browser redirect endpoint, not a JSON API.
StatusCodeWhen
400no_assertionThe button was clicked, and the provider has no initiateUrl.
400not_assertion_providerAn assertion was sent to an oidc or saml provider.
401invalid_signature, invalid_claims, expired, too_long_lived, missing_jti, missing_email, assertion_replayedThe assertion broke a rule of the contract.
403email_unverified, account_exists, account_linked_other_provider, account_linked_other_subjectThe account-linking rules refused the sign-in.
403sso_requiredAn SSO-only deployment refused a session that did not come through SSO.
404unknown_providerNo provider has that id, or SSO is off.
429rate_limit_exceededToo 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

Last updated on

On this page