From 51be5e26f9a4e2b1939c4e554691ceb845ab8e2a Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 8 Sep 2026 20:40:24 +0200 Subject: [PATCH] docs(sso): document single sign-on, and correct a stale method reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature shipped with no user-facing documentation at all — no mention in the README, docs/, the env-var table or anywhere else. That matters more than usual given it is self-hosted only: a self-hoster had no way to discover it exists, and a cloud user meeting a 404 had nothing explaining why. docs/sso.md covers registering the app at the provider, mapping directory groups to roles, requiring SSO, recovery codes, and turning all of it off again. Two Entra constraints that cost real time are called out: only Security and Microsoft 365 groups can be assigned to an application (distribution lists cannot), and group assignment does not cascade to nested groups. It also records, rather than glosses, how tool restriction actually behaves: the per-server endpoint filters `tools/list`, while the global `/mcp` endpoint lists every tool of the workspace and enforces only on `tools/call`. A restricted user can never invoke a tool their roles do not grant, but on the global endpoint they can still see the inventory — so the doc points people at a per-server endpoint when they rely on role restriction. Also fixes a comment in role-sync.service.ts that pointed at `RolesService.getUserToolAccess`, which does not exist; the method is `getAllowedToolIds`. --- README.md | 1 + docs/deployment.md | 1 + docs/guides.md | 1 + docs/sso.md | 250 ++++++++++++++++++ .../role-sync.service.spec.ts | 2 +- .../identity-providers/role-sync.service.ts | 2 +- 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 docs/sso.md diff --git a/README.md b/README.md index 80bf82f4..1ebef80e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ The interactive setup handles everything: deployment mode, domain & HTTPS (autom - **Full auth** — OAuth2 (PKCE + Client Credentials), Bearer, API Key, Basic, WS-Security, client certificates, [LOGIN_TOKEN](docs/connectors/login-token-auth.md) and OAuth 1.0a handshakes - **Audit logging** — every tool call logged with input, output, duration, status - **Roles & access control** — tool-level whitelisting per custom role, per-user MCP API keys +- **[Single sign-on](docs/sso.md)** — Microsoft Entra ID, Google, Okta, Auth0 and generic OIDC; AnythingMCP roles synced from your directory groups on every sign-in, so joiners and leavers are handled where they already are (self-hosted only) - **Environment variables** — per-connector `{{VAR}}` interpolation, hidden from the AI - **Docker ready** — `docker compose up` and you're running diff --git a/docs/deployment.md b/docs/deployment.md index 78a76f45..0f917dcc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -350,6 +350,7 @@ curl -s http://localhost:4000/api/mcp-api-keys \ | `NEXTAUTH_SECRET` | No | NextAuth secret for frontend | | `FRONTEND_URL` | No | Frontend URL for email links (default: `http://localhost:3000`) | | `MCP_AUTH_MODE` | No | MCP auth: `none`, `legacy`, `oauth2`, `both` (default: `oauth2`) | +| `DEPLOYMENT_MODE` | No | `self-hosted` (default) or `cloud`. Anything other than `cloud` is self-hosted, so a community operator who never sets it keeps every self-hosted feature — including [single sign-on](sso.md) | | `MCP_BEARER_TOKEN` | No | Bearer token for legacy MCP auth | | `MCP_API_KEY` | No | API key for legacy MCP auth | | `SERVER_URL` | No | Server URL for OAuth2 metadata (default: `http://localhost:4000`) | diff --git a/docs/guides.md b/docs/guides.md index 698c1490..d5644174 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -41,6 +41,7 @@ Per-adapter setup guides (English, German, Italian and more) live at **[anything | [Knowledge Graph & AI skills](knowledge-graph.md) | Per-workspace data-relationship map served over MCP, plus AI skills composed into server instructions | | [Deployment guide](deployment.md) | Docker, production setup, reverse proxy, env vars | | [Authentication](deployment.md#authentication) | OAuth2, JWT, API keys, MCP auth modes | +| [Single sign-on](sso.md) | Entra ID, Google, Okta & OIDC; role sync from directory groups; recovery codes — **self-hosted only** | | [License FAQ](license-faq.md) | Plain-language AGPL explanation | --- diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 00000000..540ec2e8 --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,250 @@ +# Single Sign-On (Microsoft Entra ID, Google, Okta, Auth0, OIDC) + +Let members sign in with your organization's identity provider instead of a +password, and keep their AnythingMCP roles in step with your directory groups. + +> **Self-hosted only.** SSO is not available on AnythingMCP Cloud +> (`cloud.anythingmcp.com`) and every route below answers **404** there. The +> feature assumes the workspace owns its own directory, tenant and operator; +> in a shared, multi-tenant deployment it would let any customer point a +> workspace at an arbitrary directory and provision accounts from it. Run your +> own instance (Docker) to use it — see [deployment.md](deployment.md). + +--- + +## What you get + +| | | +|---|---| +| **Sign-in** | Members authenticate at your IdP; no AnythingMCP password | +| **Account linking** | An existing password account can attach an IdP identity | +| **Role sync** | Directory groups (or app roles) grant AnythingMCP roles at every sign-in | +| **Require SSO** | Turn off password sign-in for the whole workspace | +| **Recovery codes** | Single-use break-glass credentials for when the IdP is unreachable | + +Supported types: **Entra ID**, **Google**, **GitHub**, **Okta**, **Auth0**, and +any spec-compliant **OIDC** provider. + +--- + +## 1. Register the application at your provider + +The steps below are for **Microsoft Entra ID**; other providers follow the same +shape. + +1. **Entra admin center → App registrations → New registration.** + - Supported account types: *Accounts in this organizational directory only* + unless you deliberately want guests. + - Redirect URI (**Web**): `https:///auth/sso/callback` +2. Note the **Application (client) ID** and the **Directory (tenant) ID** from + the Overview page. +3. **Certificates & secrets → New client secret.** Copy the *Value* (not the + Secret ID) — it is shown only once. Note the expiry date; Microsoft caps + secrets at 24 months. +4. **Token configuration → Add groups claim** if you want role sync: + - Select **Groups assigned to the application**. This emits only the groups + a user belongs to *that are also assigned to this app*, which keeps the + token small and avoids the overage described below. + - Format: **Group ID**. +5. **Enterprise applications → your app → Users and groups → Add user/group.** + Assign the groups you intend to map. Nothing appears in the token until a + group is assigned here. + +> **Only Security and Microsoft 365 groups can be assigned to an application.** +> Mail-enabled *distribution lists* cannot, so a directory that organises people +> into distribution lists needs security groups adding before role sync can use +> them. + +> **Group assignment does not cascade to nested groups.** A user in a child +> group of an assigned parent will not receive the claim. + +## 2. Configure the provider in AnythingMCP + +**Settings → Single sign-on → Add provider** (workspace ADMIN only). + +| Field | Value | +|---|---| +| Provider | Microsoft Entra ID | +| Display name | Shown on the sign-in button, e.g. "Sign in with Microsoft" | +| Directory (tenant) ID | The tenant GUID. A domain like `contoso.onmicrosoft.com` is **not** accepted | +| Client ID | Application (client) ID | +| Client secret | The secret *Value* | +| Secret expires on | Optional, but recording it turns a silent expiry into a warning | +| Create accounts on first sign-in | **Off by default** — see below | +| Active | Leave on | + +Use **Test** to fetch the provider's discovery document and confirm the issuer +matches before anyone tries to sign in. + +### Just-in-time provisioning + +`Create accounts on first sign-in` is **off** by default and should stay off +unless you mean it: with it on, anyone who can authenticate at your directory — +including B2B guests invited for unrelated reasons — joins the workspace as a +Viewer. With it off, a user must already exist in AnythingMCP. + +### The sign-in link + +Providers are **not** listed on a shared sign-in page in cloud-style +deployments, so each provider has an opaque entry point: + +``` +https:///sso/ +``` + +The Single sign-on settings page shows it with a **Copy** button. Distribute it +to members; it is the entry point for this workspace. The id is deliberately +opaque rather than a readable slug — a guessable one would allow workspace +enumeration and typosquatting on the very URL you ask people to trust. + +## 3. Map directory groups to roles + +**Settings → Single sign-on → Role mappings.** + +Turn on **Sync roles from the directory on every sign-in** under *Edit* first, +then add one row per group: + +| Field | Notes | +|---|---| +| Group object ID | The group's **object ID**, not its name | +| Name (for humans) | Display-only label for the table | +| Workspace role | Viewer / Editor / Admin, or leave unchanged | +| MCP roles | Which tool-access roles the group grants | + +> **Why object IDs and not names.** Microsoft does not make group display names +> unique. If mappings matched on names, anyone able to create a group could +> create one called `GB_Fuehrungskreis` and grant themselves whatever that +> mapping grants. Find the object ID in **Entra → Groups → *the group* → +> Overview → Object Id**, or by typing the group name into the Azure portal's +> global search box and opening the result. + +Resolution rules: + +- A user in several mapped groups receives the **union** of their MCP roles and + the **most privileged** workspace role. +- Roles an admin assigned by hand are **never** removed by a sync, and vice + versa. +- A sync will **not** demote the last remaining admin of a workspace. The + attempt is refused and audited as `LAST_ADMIN_PROTECTION_TRIGGERED`. + +### When nothing matches + +| Fallback | Behaviour | +|---|---| +| **Grant no tools** (default) | Assign a `No access (SSO)` role that whitelists nothing | +| Leave existing roles alone | Write nothing at all | +| Grant a default role | Assign the configured default MCP roles | + +**Grant no tools is the default deliberately.** A user holding *no* MCP role at +all is treated as **unrestricted** — that is inherited behaviour, and it means +"revoke everything" written the obvious way would grant full access. The +fallback therefore assigns a real role with an empty whitelist instead. You will +see `No access (SSO)` appear under **Settings → Roles**; do not add tool access +to it. + +### Tokens that carry too many groups + +Past roughly 150 groups, Entra stops sending the list and sends a Microsoft +Graph pointer instead. AnythingMCP **does not** act on such a token: it changes +no roles and records `ROLE_SYNC_SKIPPED`. Treating the absent list as "member of +nothing" would strip the roles of exactly the people who belong to the most +groups. Selecting *Groups assigned to the application* (step 1.4) is what keeps +tokens under the limit. + +--- + +## 4. Require single sign-on + +**Settings → Single sign-on → Require single sign-on.** + +Turning this on stops password sign-in for **every member of the workspace**. +Enabling is refused unless both of these hold: + +1. **Someone has completed a dashboard sign-in through this provider.** Until + that succeeds there is no evidence the configuration works. +2. **You hold unused recovery codes.** Without them, a directory outage would + leave the workspace with no way in. + +Disabling is never gated — undoing a lockout risk should not need permission. + +Enforcement covers **every organization you belong to**, not just the active +one, because sessions can be switched between workspaces. + +## 5. Recovery codes + +**Settings → Single sign-on → Recovery codes → Generate.** + +Ten single-use codes, shown **once**. The server stores only bcrypt hashes, so +they cannot be displayed again — copy or download them and keep them somewhere +reachable *without* single sign-on, which is the situation they exist for. + +To use one: on the sign-in page choose **Use a recovery code**, enter your email +address and the code. Case and dashes are ignored. Regenerating invalidates the +whole previous set. + +--- + +## Turning SSO off + +| Goal | How | +|---|---| +| Restore password sign-in | Untick **Require single sign-on** | +| Stop a provider being usable | Untick **Active** under *Edit* | +| Remove it entirely | **Delete** the provider | + +Deleting a provider removes its role mappings and unlinks the identities that +pointed at it. Members keep their accounts and any roles an admin assigned by +hand; roles that came from a sync go with it. **Make sure at least one admin can +still sign in with a password before deleting a provider that has enforcement +on** — or turn enforcement off first. + +--- + +## How tool restriction is enforced + +Role sync decides which **MCP roles** a user holds; those roles decide which +tools they may use. Where that restriction is applied depends on the endpoint: + +| Endpoint | `tools/list` | `tools/call` | +|---|---|---| +| `/mcp/` (per server) | Filtered to the user's tools | Denied if not allowed | +| `/mcp` (global) | **Lists every tool of the workspace** | Denied if not allowed | + +A restricted user can never *invoke* a tool their roles do not grant on either +endpoint. But on the global `/mcp` endpoint they still *see* the whole +inventory, and an AI client will plan with tools it cannot use. + +**Prefer a per-server endpoint** (`Settings → MCP Servers`) when you rely on +role-based restriction. + +--- + +## Security notes + +- Identities are keyed on the provider's **immutable subject** (`oid` for + Entra), never on email. Email addresses are mutable, unverified and not + unique; keying authorization on one is the *nOAuth* vulnerability. +- Client secrets are encrypted with AES-256-GCM, bound to the provider and + organization, and never returned by the API. +- Okta, Auth0 and generic OIDC require an admin-supplied issuer and are + therefore self-hosted only even among self-hosted features' own gating. +- Every configuration change and sign-in outcome is written to the security + audit trail: `IDP_CREATED`, `IDP_UPDATED`, `IDP_SECRET_ROTATED`, + `IDP_ROLE_MAPPING_CHANGED`, `SSO_ENFORCEMENT_CHANGED`, `SSO_LOGIN_SUCCESS`, + `SSO_LOGIN_FAILED`, `ROLE_SYNC_APPLIED`, `ROLE_SYNC_SKIPPED`, + `LAST_ADMIN_PROTECTION_TRIGGERED`, `RECOVERY_CODES_GENERATED`, + `RECOVERY_CODE_USED`. +- Group object ids are **not** written to the audit trail. Sign-in events record + the *count* of groups presented, which answers "did the directory send + anything at all?" without persisting directory structure. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| Sign-in works, roles never change | Role sync is off, or no mapping matches. Check `groupCount` on the `SSO_LOGIN_SUCCESS` audit event — `0` means the token carried no groups | +| `groupCount` is 0 | The group is not **assigned to the application** in Entra, or the user is not a member of an assigned group, or the groups claim is missing from *Token configuration* | +| Everyone lands on `No access (SSO)` | Mappings use group *names* instead of object IDs, or the wrong tenant | +| `ROLE_SYNC_SKIPPED` with `reason: overage` | The user is in too many groups; switch the claim to *Groups assigned to the application* | +| Cannot enable **Require single sign-on** | Complete one dashboard sign-in through the provider, and generate recovery codes | +| Every SSO route returns 404 | You are on AnythingMCP Cloud. SSO is self-hosted only | diff --git a/packages/backend/src/identity-providers/role-sync.service.spec.ts b/packages/backend/src/identity-providers/role-sync.service.spec.ts index 27097f51..c04c570b 100644 --- a/packages/backend/src/identity-providers/role-sync.service.spec.ts +++ b/packages/backend/src/identity-providers/role-sync.service.spec.ts @@ -139,7 +139,7 @@ describe('RoleSyncService', () => { }); // The whole point of DENY_ALL. Deleting the user's grants would leave them - // with none, and `getUserToolAccess` reads "no role" as UNRESTRICTED — so + // with none, and `getAllowedToolIds` reads "no role" as UNRESTRICTED — so // the obvious implementation grants full access. it('DENY_ALL assigns an empty role instead of leaving the user role-less', async () => { const out = await service.syncOnLogin(makeProvider(), USER, { groups: ['nope'] }); diff --git a/packages/backend/src/identity-providers/role-sync.service.ts b/packages/backend/src/identity-providers/role-sync.service.ts index df9830e8..382af4d8 100644 --- a/packages/backend/src/identity-providers/role-sync.service.ts +++ b/packages/backend/src/identity-providers/role-sync.service.ts @@ -417,7 +417,7 @@ export class RoleSyncService { /** * DENY_ALL cannot be implemented by deleting the user's grants. * - * `RolesService.getUserToolAccess` returns `null` — meaning UNRESTRICTED — + * `RolesService.getAllowedToolIds` returns `null` — meaning UNRESTRICTED — * for a user with no role at all, which is the behaviour inherited from the * single-FK era. So "revoke everything" written the obvious way produces * full access: the precise failure this fallback exists to prevent.