# Single sign-on

With single sign-on (SSO), your members sign in to the RouterPlus console through your identity
provider. RouterPlus supports OpenID Connect (OIDC) and SAML 2.0, with Okta, Microsoft Entra ID
and Google Workspace.

An organization can have several connections. Each connection signs in the emails of its
domains. For example, `acme.com` signs in through Okta and `acme.co.uk` through Entra ID.

- Members sign in at `https://demo.routerplus.com/sso` with their work email. RouterPlus sends them to the
  connection for that domain.
- The sign-in link for one connection is
  `https://demo.routerplus.com/sso/start?connection=<connection id>`.
- Email links and the normal sign-in page continue to work.

In the console, open Team > Single sign-on.

## Set up a connection

1. Create the application in your identity provider. See the sections below.
2. Create the connection with a management key: `POST https://demo-api.routerplus.com/v1/sso/connections`.
3. Add the DNS TXT record that the answer shows for each domain.
4. Verify the domains: `POST https://demo-api.routerplus.com/v1/sso/connections/<id>/verify-domains`.
5. Add your members to the organization, or turn on just-in-time provisioning.

A connection signs in no one until you verify its domain.

## The URLs your identity provider needs

| Use | URL |
|---|---|
| OIDC redirect URI (sign-in redirect URI) | `https://demo.routerplus.com/sso/oidc/callback` |
| SAML assertion consumer service (ACS, reply URL) | `https://demo.routerplus.com/sso/saml/acs` |
| SAML audience (entity id of RouterPlus) | the connection's `sp_entity_id` |
| SAML metadata of RouterPlus | `https://demo.routerplus.com/sso/saml/metadata?connection=<connection id>` |

## Create a connection

OIDC:

```bash
curl https://demo-api.routerplus.com/v1/sso/connections \
  -H "Authorization: Bearer $ROUTERPLUS_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "oidc",
    "name": "Okta",
    "email_domains": ["acme.com"],
    "issuer": "https://acme.okta.com",
    "client_id": "0oa1b2c3d4",
    "client_secret": "...",
    "default_role": "viewer",
    "jit_provisioning": false
  }'
```

SAML:

```bash
curl https://demo-api.routerplus.com/v1/sso/connections \
  -H "Authorization: Bearer $ROUTERPLUS_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "saml",
    "name": "Entra ID",
    "email_domains": ["acme.com"],
    "idp_entity_id": "https://sts.windows.net/<tenant id>/",
    "idp_sso_url": "https://login.microsoftonline.com/<tenant id>/saml2",
    "idp_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
  }'
```

The response is `201`, with the connection in `data`.

### Fields

| Field | Kind | Meaning |
|---|---|---|
| `kind` | both | `oidc` or `saml`. You cannot change it later. |
| `name` | both | A label, 1 to 100 characters. |
| `email_domains` | both | 1 to 50 domains. The connection signs in only emails at these domains. |
| `enabled` | both | `false` stops all sign-ins through the connection. Default `true`. |
| `default_role` | both | `viewer` or `admin`. The role a member gets from just-in-time provisioning. Default `viewer`. |
| `jit_provisioning` | both | `true` adds a new email to the organization at its first sign-in. Default `false`. |
| `issuer` | OIDC | The issuer URL of your identity provider, `https` only. RouterPlus reads `<issuer>/.well-known/openid-configuration`. |
| `client_id` | OIDC | The client id of the application. |
| `client_secret` | OIDC | The client secret. Leave it out for a public client with PKCE only. |
| `allowed_tenant_ids` | OIDC | Up to 50 Entra ID tenant ids that can sign in. See [Tenant filtering](#tenant-filtering). |
| `idp_entity_id` | SAML | The issuer (entity id) of your identity provider. |
| `idp_sso_url` | SAML | The single sign-on URL of your identity provider, `https` only. |
| `idp_certificate` | SAML | The signing certificate of your identity provider, in PEM or as bare base64. RSA keys only. |
| `sp_entity_id` | SAML | The audience that your identity provider puts in the assertion. Default `urn:routerplus:sso:<connection id>`. |

RouterPlus stores the client secret encrypted and never returns it. A connection with a secret
shows `"client_secret": "__secret__"`.

### Routes

All routes take a management key. They also work under `/api/v1`.

| Route | What it does |
|---|---|
| `GET /v1/sso/connections` | Lists your organization's connections. |
| `POST /v1/sso/connections` | Creates a connection. |
| `GET /v1/sso/connections/<id>` | Shows one connection. |
| `PATCH /v1/sso/connections/<id>` | Changes the fields you send. Send `"client_secret": null` to remove the secret. |
| `DELETE /v1/sso/connections/<id>` | Deletes the connection. |
| `POST /v1/sso/connections/<id>/verify-domains` | Looks up the DNS records and marks the domains verified. |

RouterPlus writes every change to the audit log (`GET /v1/audit`).

## Verify your domains

Each domain in the response has a `verification` object:

```json
{
  "domain": "acme.com",
  "verified": false,
  "verification": { "type": "TXT", "name": "_routerplus-sso.acme.com", "value": "routerplus-sso=4f1c..." }
}
```

1. At your DNS provider, add a TXT record with that name and value.
2. Call `verify-domains`. The response lists each domain with `verified`. A domain that fails
   also has a `reason`.
3. If a domain fails, call `verify-domains` again after your DNS change propagates.

- In your organization, a domain belongs to one connection.
- A verified domain belongs to one organization. No other organization can verify or add it.
- A domain stays verified when you change the connection's other fields. A domain that you
  remove loses its verification.

## Okta

OIDC:

1. In the Okta admin console, open **Applications**, then **Create App Integration**.
2. Select **OIDC - OpenID Connect** and **Web Application**.
3. Set the sign-in redirect URI to `https://demo.routerplus.com/sso/oidc/callback`.
4. Assign the application to your people or groups.
5. Copy the client id and client secret. The issuer is your Okta domain, for example
   `https://acme.okta.com`. With a custom authorization server, the issuer is that server's
   URL, for example `https://acme.okta.com/oauth2/default`.

SAML:

1. Create an app integration and select **SAML 2.0**.
2. Set **Single sign-on URL** to `https://demo.routerplus.com/sso/saml/acs`.
3. Set **Audience URI (SP Entity ID)** to the connection's `sp_entity_id`.
4. Set **Name ID format** to `EmailAddress` and **Application username** to `Email`.
5. Open **View SAML setup instructions**. Copy the identity provider issuer, the single sign-on
   URL and the X.509 certificate.

To use the default `sp_entity_id`, create the connection first. Then add the Okta fields with
`PATCH`.

## Microsoft Entra ID

OIDC:

1. In the Entra admin center, open **App registrations**, then **New registration**.
2. Add a **Web** redirect URI: `https://demo.routerplus.com/sso/oidc/callback`.
3. Under **Certificates & secrets**, create a client secret.
4. Under **Token configuration**, add the optional claim `email` to the ID token.
5. The issuer for a single-tenant app is `https://login.microsoftonline.com/<tenant id>/v2.0`.

Entra ID does not send `email_verified`. Make sure that each user's `email` attribute
holds their real work email.

SAML:

1. Open **Enterprise applications**, then **New application**, then
   **Create your own application**.
2. Under **Single sign-on**, select **SAML**.
3. Set **Identifier (Entity ID)** to the connection's `sp_entity_id`.
4. Set **Reply URL (Assertion Consumer Service URL)** to `https://demo.routerplus.com/sso/saml/acs`.
5. Download **Certificate (Base64)**. Copy **Login URL** (the `idp_sso_url`) and
   **Microsoft Entra Identifier** (the `idp_entity_id`).

RouterPlus reads the email from the NameID when it is an email. Otherwise it reads the
`emailaddress` claim.

## Google Workspace

OIDC:

1. In Google Cloud console, open **APIs & Services**, then **Credentials**.
2. Create an **OAuth client ID** of type **Web application**.
3. Add the authorized redirect URI `https://demo.routerplus.com/sso/oidc/callback`.
4. The issuer is `https://accounts.google.com`.

Set the OAuth consent screen to **Internal**. Then only your Workspace accounts can sign in.

SAML:

1. In the Google Admin console, open **Apps**, then **Web and mobile apps**, then
   **Add custom SAML app**.
2. Copy the SSO URL, the entity id and the certificate.
3. Set **ACS URL** to `https://demo.routerplus.com/sso/saml/acs` and **Entity ID** to the connection's
   `sp_entity_id`.
4. Set **Name ID format** to `EMAIL` and **Name ID** to **Primary email**.
5. Turn the app on for your organizational units.

## Tenant filtering

A connection signs in a person only when both conditions are true:

- The email's domain is one of the connection's verified `email_domains`.
- For Entra ID with `allowed_tenant_ids` set, the `tid` claim is in that list.

A multi-tenant Entra ID app uses the issuer
`https://login.microsoftonline.com/organizations/v2.0` or `/common/v2.0`. Any Entra tenant can
sign in to such an app, and a tenant admin can set any email on a user. Thus a connection with a
multi-tenant issuer needs `allowed_tenant_ids`. Without the list, the connection refuses every
sign-in.

## Just-in-time provisioning

- `jit_provisioning` off: only existing members sign in. Add members in the console or with
  [SCIM](/docs/scim).
- `jit_provisioning` on: a person with an email at a verified domain joins the organization at
  the first sign-in, with `default_role` (`viewer` or `admin`).

SSO never makes an owner. A member that an admin turned off stays off.

## What a sign-in checks

OIDC:

- Authorization code flow with PKCE (`S256`), `state` and `nonce`.
- The ID token signature, against the identity provider's published keys.
- `iss`, `aud` (your `client_id`), `exp`, `nbf`, `iat` and `nonce`.
- `email_verified` must be `true` when the claim is present.

SAML:

- RouterPlus sends an AuthnRequest with the HTTP-Redirect binding. It reads the response with the
  HTTP-POST binding.
- The response or the assertion must be signed with RSA-SHA256 and exclusive canonicalization.
  Every signature in the response must verify with `idp_certificate`.
- `Destination`, `Recipient`, `Audience`, `InResponseTo`, `NotBefore` and `NotOnOrAfter` must
  match.
- RouterPlus accepts an assertion one time.
- RouterPlus refuses encrypted assertions, and XML with a DOCTYPE or entity declarations.

The sign-in must finish in the browser that started it, within 10 minutes. Sign-ins start only
from RouterPlus. RouterPlus refuses an unsolicited (IdP-initiated) SAML response.

## Errors

The sign-in page shows the reason.

| Message | Fix |
|---|---|
| Single sign-on is not set up for this email domain | Add the domain to a connection, verify it and set `enabled` to `true`. |
| This connection does not sign in emails at `<domain>` | Add the domain to the connection and verify it. |
| You are not a member of this organization | Add the person as a member, or turn on `jit_provisioning`. |
| Your account in this organization is turned off | An admin turned the member off. Turn the member on again. |
| The ID token has no email claim | Add the `email` scope or the `email` optional claim. |
| Your identity provider has not verified this email address | Verify the email in the identity provider. |
| Your Entra ID tenant is not allowed | Add the tenant id to `allowed_tenant_ids`. |
| Set allowed tenant ids on the connection | A multi-tenant Entra ID issuer needs `allowed_tenant_ids`. |
| The identity provider did not complete the sign-in | Check `client_id`, `client_secret` and the redirect URI. |
| The SAML response was refused: ... audience ... | Set the audience in the identity provider to `sp_entity_id`. |
| The SAML response was refused: ... signature ... | Upload the identity provider's current signing certificate. |
| The SAML response was refused: ... encrypted assertions ... | Turn assertion encryption off. |
| This sign-in request has expired or was already used | Start the sign-in again. |
| This sign-in was started in another browser | Start the sign-in again in this browser. |
