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
| 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. |
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"
}
]| 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. |
supabaseProvider | oidc | Validated at startup, but not used. |
initiateUrlforwards 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
initiateUrlByHostare 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
HS256and the provider'ssecret. - Audience.
audequals the provider'saudience. - Expiry.
expis present and not in the past, with a tolerance of 5 seconds for clock differences. - Lifetime.
expminusiatis at mostmaxLifetimeSeconds, measured on Nodaro's clock. Aniatmore than 5 seconds in the future is clamped, and a missingiatcounts as now. - Single use.
jtiis present and unique. Nodaro remembers eachjtiuntil 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
truefor 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
- The user clicks the SSO button on the Nodaro login page, which calls
GET /v1/sso/:providerwithout an assertion. - Nodaro redirects (
302) to the provider'sinitiateUrl. - The IdP authenticates the user and sends the browser back to
GET /v1/sso/:provider?assertion=<jwt>, optionally with&next=<path>. - Nodaro verifies the assertion, refuses replays and applies the account-linking rules.
- Nodaro redirects to
/sso?sso_token=<one-time token>. That page exchanges the one-time token for a session. - The user lands on
/projects, or on thenextpath.
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
ssoinauth.methodsand setsauth.ssoLabel. A profile withoutssoLabeldropsssofrom the methods. The button text is thessoLabel. - 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.
| 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. 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_EXISTINGsays. An unverified assertion for it is refused withemail_unverified, and an assertion from a different provider withaccount_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'sidas the OAuth provider name. So theidmust itself be the name of an OAuth provider configured in the install's Supabase Auth, such askeycloakorazure.saml: the login page passes the provider'sidas the SAML domain. Anidcannot contain a dot, so a domain such asacme.comcannot be expressed, andsamldoes not work end to end.- An assertion sent to an
oidcorsamlprovider is refused with400 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
secretis a verification key for Nodaro only, separate from the IdP's own session secret. - Assertions work once. The
jticheck refuses a submitted assertion a second time, and the lifetime cap keeps the window short. - Redirects stay on the install.
nextis 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/providersreturns onlyid,labelandkind. The assertion and the one-time token are removed from request logs. - The exchange is rate limited per IP address.
Frequently asked questions
Related
Single sign-on
Editions and surface profiles
Configuration
External wallets
Authentication
Last updated on
OAuth apps
Register an OAuth app, send users to the Nodaro consent screen, exchange the code for a 90-day access token, and call the API on each user's behalf.
Picker catalogs
Build Nodaro's pickers in your own app with @nodaro/prompts, turn selections into prompt clauses, translate labels, show pictures or read catalogs over the API.