# SSO and SCIM

Sign people in through your organization's identity provider, provision them with SCIM, and map IdP groups to roles.

Connect your organization's identity provider (IdP) and members sign in through it instead of GitHub or Google. With SCIM, the IdP also adds people and groups to Parity and removes them when they leave. Any Admin of the organization sets this up.

Parity connects with OpenID Connect: the authorization code flow with PKCE, requesting the scopes `openid email profile`. SAML is not supported. Okta, Microsoft Entra ID, Google Workspace, JumpCloud, OneLogin, Ping and Keycloak all support OIDC for custom apps.

## Connect an IdP [#connect-an-idp]

1. In your IdP, create an OIDC web application. Register Parity's redirect URI as its sign-in redirect URI:

   ```text title="Redirect URI"
   https://console.parity.ninja/auth/sso/callback
   ```

2. In the Parity console, open **Sign-in** from the account menu and choose **Connect IdP**.
3. Fill in the fields below and choose **Connect**. Parity reads the issuer's discovery document before it saves, so a wrong issuer fails here rather than at someone's first sign-in.
4. Sign in at the connection's sign-in URL to finish the check.

| Field | Value |
| --- | --- |
| **Name** | What people see on errors and in the Members list, such as `Okta`. Up to 60 characters. |
| **Sign-in name** | The organization's name at sign-in, such as `acme`. Lowercase letters, digits and dashes, up to 40, unique across Parity. |
| **Issuer** | The IdP's issuer URL, exactly as its discovery document states it, such as `https://acme.okta.com`. Must be `https` on a public address. |
| **Client ID** | From the IdP application. |
| **Client secret** | From the IdP application. Stored encrypted. Leave it empty when editing to keep the stored one. |
| **Groups claim** | The ID token claim that lists the person's groups. Default `groups`. Clear it to ignore groups in the token. |
| **Access** | **Anyone the IdP signs in** (default) or **Only people provisioned by SCIM**. See [Access](#access). |
| **Default role** | The role a person gets the first time they sign in. Default **Viewer**. |

Parity authenticates to the IdP's token endpoint with HTTP Basic (`client_secret_basic`). It accepts ID tokens signed with RS, PS or ES algorithms at 256, 384 or 512 bits, and refuses `none` and HMAC. It picks up the IdP's key rotation without any change on your side.

Each connection's page shows the values you give the IdP:

| Value | |
| --- | --- |
| Sign-in URL | `https://console.parity.ninja/sso/<sign-in name>` |
| Redirect URI | `https://console.parity.ninja/auth/sso/callback` |
| SCIM URL | `https://api.parity.ninja/scim/v2` |

## Sign in [#sign-in]

People choose **Sign in with SSO** on the console's sign-in page and enter the organization's sign-in name. They can also go straight to the sign-in URL: bookmark it, or set it as the app tile's URL in the IdP. Parity does not route people to an IdP by their email domain.

A person who signs in through SSO is a member of that organization only. The email their IdP sends is shown in Parity and recorded on their tokens, but it is never treated as a verified email: it cannot accept another organization's invite or create an organization.

A console session lasts 12 hours. Parity checks the IdP only at sign-in; removal through SCIM ends a session sooner (see [Offboarding](#offboarding)).

## Access [#access]

| Access | Who gets in |
| --- | --- |
| **Anyone the IdP signs in** | Anyone the IdP authenticates for the Parity app. Parity creates their membership at their first sign-in, with the default role. Control who gets in by assigning the app in the IdP. |
| **Only people provisioned by SCIM** | Only people the IdP has provisioned to Parity through SCIM and not deactivated. Anyone else is refused. |

Under either setting, someone the IdP has deactivated through SCIM is refused.

## SCIM provisioning [#scim-provisioning]

SCIM 2.0 lets the IdP push people and groups to Parity as they change. Point the IdP at the SCIM URL and give it a SCIM token.

- **Tokens.** Under **SCIM** on the connection's page, choose **Create token**. The token is shown once. Revoke tokens from the same list, which shows when each was last used. Several tokens can be live at once, so you can rotate without a gap: create the new one, update the IdP, then revoke the old one. Removing the connection revokes all its tokens.
- **Authentication.** The IdP sends the token as `Authorization: Bearer <token>`.

| | Supported |
| --- | --- |
| Resources | `Users`, `Groups`, `ServiceProviderConfig`, `ResourceTypes`, `Schemas` |
| Operations | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` on Users and Groups. No bulk, sort, ETag or password change. |
| Filters | `eq`, joined by `and`, on `userName`, `externalId`, `emails`, `displayName`, `id`, `active` and group `members`. Value filters such as `emails[type eq "work"]` and `members[value eq "<id>"]`. Anything else is refused with `400 invalidFilter`. |
| Pagination | `startIndex` and `count`; default 100, at most 1,000 per page |
| `PATCH` | `add`, `replace` and `remove`, in any letter case. Ops with or without a `path`; paths with value filters (`emails[type eq "work"].value`, created when missing) and extension URNs; `"True"`/`"False"` strings for booleans; removing members by value list or filter. A group `PATCH` answers `204`. |
| Groups | `excludedAttributes=members` on reads. Members must be users already pushed to Parity. |
| Other attributes | Kept and returned as sent. `id`, `meta` and `schemas` are set by Parity. |
| Body size | Up to 8 MB per request, enough for a group of about 50,000 members |

Provisioning someone records them in the organization's directory. They become a member when they first sign in.

### How a sign-in finds its provisioned user [#how-a-sign-in-finds-its-provisioned-user]

When someone signs in, Parity matches them to a provisioned user in this order:

1. Their earlier sign-in through this connection.
2. The IdP's user ID: SCIM `externalId` equal to the ID token's `sub`, or to Entra ID's `oid`.
3. Their email, against the SCIM `userName` or email. An email the ID token marks `email_verified: false` never matches.

## Roles from groups [#roles-from-groups]

Map IdP groups to roles on the **Groups** page in the account menu. Select groups, choose **Set role**, and pick **Viewer**, **Developer**, **Admin** or **None**.

- **Where groups come from.** From SCIM, or from the groups claim in the ID token at each sign-in. The **From** column says which. Groups from the claim count only for people SCIM doesn't manage.
- **Effective role.** A member's role is the higher of their own role and the roles of their mapped groups. Parity re-reads it within seconds of a change, so mapping or unmapping a group needs no new sign-in.
- **Limits.** You can't map a group to a role above your own. A sign-in records at most 500 groups from the claim.
- **Admins.** A group can grant Admin, but an organization always keeps at least one Admin assigned directly who signs in with GitHub or Google, so the IdP can never remove every Admin.
- **Deleted groups.** A group the IdP deletes stays on the list, marked deleted, while a role still points at it. Its members lose that role.

The Members page shows each member's effective role, the groups it comes from, and the IdP that manages them.

## Add people from the directory [#add-people-from-the-directory]

Once the IdP has a directory in Parity, through SCIM or sign-ins, the invite dialog on **Members** becomes **Add people**. It searches the IdP's people and groups as you type, and still takes guest emails for people outside the IdP.

- Picking a **group** maps it to the role you choose, at once.
- Picking a **person** sets the role their membership starts with. Someone who has already signed in gets it immediately.
- **Guests by email** get an ordinary invite and sign in with GitHub or Google.

## Offboarding [#offboarding]

| What happens | Effect |
| --- | --- |
| The IdP deactivates or deletes a user through SCIM | Their membership ends at once, the project tokens created for them are revoked, and their console session fails its next check. |
| You remove a SCIM-managed member in Parity | Refused with `409 managed_by_idp`. Unassign them from the Parity app in the IdP. |
| You remove a connection | Every membership it created ends and its SCIM tokens stop working. Members who sign in with GitHub or Google stay. |
| Without SCIM, the IdP removes someone | They can't sign in again, but a session already open lasts up to 12 hours. Remove them on **Members** to end it now. Under **Anyone the IdP signs in**, a member removed only in Parity rejoins at their next sign-in, so unassign them in the IdP too. |

The organization's last Admin who signs in with GitHub or Google can't be removed or demoted, so deprovisioning through the IdP never leaves the organization without an Admin.

## Okta [#okta]

<Callout type="warning" title="Not yet tested against Okta">
These steps follow Okta's documentation. Parity's SSO and SCIM are tested end to end against Keycloak, and against Okta's documented SCIM requests, but not yet against a live Okta org.
</Callout>

1. In the Okta Admin Console, under **Applications**, create an app integration with the sign-in method **OIDC - OpenID Connect** and the application type **Web Application**.
2. Add Parity's redirect URI under **Sign-in redirect URIs**. To start sign-in from the Okta dashboard, set the initiate login URI to the connection's sign-in URL.
3. Copy the client ID and client secret from the **General** tab. The issuer is your Okta org URL, such as `https://acme.okta.com`.
4. For groups in the token: on the **Sign On** tab, edit **OpenID Connect ID Token**, set the groups claim type to **Filter**, name the claim `groups`, and choose a filter such as **Matches regex** `.*`.
5. Assign people or groups on the **Assignments** tab.
6. For SCIM: on the **General** tab, edit **App Settings** and set **Provisioning** to **SCIM**. On the **Provisioning** tab, under **Integration**:
   - **SCIM connector base URL**: the SCIM URL.
   - **Unique identifier field for users**: `userName`.
   - **Supported provisioning actions**: **Push New Users**, **Push Profile Updates** and **Push Groups**.
   - **Authentication Mode**: **HTTP Header**, with the SCIM token as the bearer token.
7. Under **To App**, enable creating users, updating user attributes and deactivating users. Push the groups you want to map on the **Push Groups** tab.

Okta doesn't offer SCIM on OIDC integrations created in its Classic experience. In that case, keep the OIDC app for sign-in and add a separate SWA app integration for provisioning only, assigned to the same people. Both carry the same Okta user ID, so sign-ins still link.

Okta's `sub` is the Okta user ID, which Okta also sends as the SCIM `externalId`, so a sign-in links straight to its provisioned user.

## Microsoft Entra ID [#microsoft-entra-id]

<Callout type="warning" title="Not yet tested against Entra ID">
These steps follow Microsoft's documentation. Parity's SSO and SCIM are tested end to end against Keycloak, and against Entra's documented SCIM requests, but not yet against a live Entra tenant.
</Callout>

1. In the Microsoft Entra admin center, under **Enterprise applications**, choose **New application**, then **Create your own application**, and pick the non-gallery option. This creates an app registration and an enterprise application that can take provisioning.
2. In the app registration, add Parity's redirect URI on the **Web** platform. Under **Certificates & secrets**, create a client secret and copy its value.
3. The issuer is `https://login.microsoftonline.com/<tenant-id>/v2.0`. The client ID is the application (client) ID.
4. Under **Token configuration**, add the optional claim `email` to the ID token. Without it, Parity uses `preferred_username` when it is an email address.
5. For SCIM: in the enterprise application, open **Provisioning**, set **Provisioning Mode** to **Automatic**, enter the SCIM URL as **Tenant URL** and the SCIM token as **Secret Token**, and choose **Test Connection**. Assign users and groups under **Users and groups**, then start provisioning.

Prefer SCIM for groups. Entra's groups claim carries group object IDs, not names, so groups from the claim appear on the Groups page as IDs. A user in too many groups gets a link instead of groups in the token ("groups overage"); Parity then keeps their previous groups. If you use the claim, send only **Groups assigned to the application**.

Entra provisions on a cycle, roughly every 40 minutes, so new people and group changes reach Parity with that delay.

Entra's `sub` differs per application. Parity links a sign-in to its provisioned user by Entra's `oid` when SCIM `externalId` holds the object ID, and otherwise by email.

## Troubleshooting [#troubleshooting]

These are the messages people see when sign-in fails. `<IdP>` is the connection's **Name**.

| Message | Cause | Fix |
| --- | --- | --- |
| No organization signs in as `<name>`. Check the name with your IT team. | No connection has that sign-in name. | Use the sign-in name from the connection's page. |
| `<IdP>` didn't answer. Try again, or ask an Admin to check the SSO settings. | Parity couldn't read the issuer's discovery document. | Check the issuer on the connection. |
| This sign-in expired or didn't start here. Sign in again. | More than 10 minutes passed, or the IdP sent the person straight to the redirect URI without Parity starting the sign-in. | Start at the sign-in URL. Point app tiles at the sign-in URL, not the redirect URI. |
| `<IdP>` didn't sign you in (`<error>`). If you should have access, ask your IT team to assign you the Parity app. | The IdP refused, often because the person isn't assigned the app. | Assign the person or their group in the IdP. |
| Sign-in with `<IdP>` failed. Try again, or ask an Admin to check the SSO settings. | The code exchange or ID token check failed: wrong client secret, unregistered redirect URI, or a token for another client. | Check client ID, client secret and redirect URI against the IdP. |
| Your `<IdP>` account isn't assigned to Parity yet. Ask your IT team to assign you the Parity app. | Access is **Only people provisioned by SCIM** and no provisioned user matches. | Assign the person in the IdP and wait for provisioning. |
| Your `<IdP>` account is deactivated for Parity. Ask your IT team. | The IdP deactivated the user through SCIM. | Reactivate them in the IdP. |
| Another `<IdP>` account already signed in to Parity as `<email>`. Ask an Admin to check the org's directory. | A different IdP user ID already signed in with this email. Parity refuses rather than merge them. | Check the IdP for a reused or renamed account. |
| This organization's SSO was removed. Ask an Admin. | The connection was removed during sign-in. | Reconnect the IdP, or sign in with GitHub or Google. |
| Your account has no access to this organization. Ask an Admin. | The membership was refused after sign-in. | Check the person on **Members**. |

SCIM errors go back to the IdP in SCIM's error format. An unknown or revoked token gets `401` with a message to create a new one under **Sign-in**.

## API [#api]

Setting up SSO needs `sso.manage`, which only Admins have and agents never do. Searching the directory and mapping groups need `members.manage`. Agents can't change group or directory roles. All endpoints act on the organization of the calling token.

<Endpoint method="GET" path="/v0/org/sso" permission="sso.manage" />

Lists the connections, each with `sign_in_url`, `redirect_uri`, `scim_url` and its live `scim_tokens`. The client secret is never returned.

<Endpoint method="POST" path="/v0/org/sso" permission="sso.manage" />

```json title="Request"
{
  "name": "Okta",
  "slug": "acme",
  "issuer": "https://acme.okta.com",
  "client_id": "0oa1b2c3d4",
  "client_secret": "…",
  "groups_claim": "groups",
  "access": "jit",
  "default_role": "Viewer"
}
```

`access` is `jit` (anyone the IdP signs in) or `scim` (only provisioned people). `groups_claim`, `access` and `default_role` are optional, with the defaults above. Returns `201` with the connection, after checking the issuer's discovery document.

<Endpoint method="PATCH" path="/v0/org/sso/{connection}" permission="sso.manage" />

Changes the fields sent. An empty `client_secret` keeps the stored one. A changed issuer is checked again.

<Endpoint method="DELETE" path="/v0/org/sso/{connection}" permission="sso.manage" />

Removes the connection, revokes its SCIM tokens and ends the memberships it created. Returns `memberships_ended`.

<Endpoint method="POST" path="/v0/org/sso/{connection}/test" permission="sso.manage" />

Checks the issuer's discovery document. The full check is a sign-in at the sign-in URL.

<Endpoint method="POST" path="/v0/org/sso/{connection}/scim-tokens" permission="sso.manage" />

Returns `201` with `token`, shown once, its metadata as `scim_token`, and `scim_url`.

<Endpoint method="DELETE" path="/v0/org/sso/{connection}/scim-tokens/{token}" permission="sso.manage" />

Revokes a SCIM token by its `id`. Returns `204`.

<Endpoint method="GET" path="/v0/org/directory?q=" permission="members.manage" />

Searches the directory's people and groups by name or email. `limit` is 1 to 50, default 20. Returns an empty list when no IdP is connected.

<Endpoint method="PUT" path="/v0/org/directory/users/{user}/role" permission="members.manage" />

Sets the role a directory user starts with, `{"role": "Developer"}`. If they have already signed in, their membership changes at once.

<Endpoint method="GET" path="/v0/org/groups" permission="members.manage" />

Lists groups with their mapped role and active member count. Takes `q`, `limit` (1 to 200, default 50) and `cursor` from the previous page's `next`.

<Endpoint method="PUT" path="/v0/org/groups/{group}/role" permission="members.manage" />

Maps a group to a role, `{"role": "Developer"}`. `{"role": ""}` removes the mapping. A role above your own is refused with `403`.
