diff --git a/README.md b/README.md index 6267b0d..65f2c7c 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,38 @@ Write tools appear only when `BOOKSTACK_READ_ONLY=false`: `create_page`, ## 1. Create the BookStack API token -1. In BookStack, make a dedicated user for Claude. Its roles decide exactly what the - MCP server can see, so give it the narrowest access you're comfortable with. +BookStack's API is authenticated **separately from web login**. API tokens work +the same whether your instance uses standard, LDAP, SAML, or OIDC authentication — +BookStack cannot accept an OIDC access token for API calls, only its own tokens. +So an OIDC-backed instance changes nothing about this step except how you get the +token into existence. + +1. Make a dedicated user for Claude. Its roles decide exactly what the MCP server + can see, so give it the narrowest access you're comfortable with. 2. Settings → Roles → (that user's role) → enable **Access System API**. -3. Log in as that user → profile menu → **Edit Profile** → **API Tokens** → **Create Token**. -4. Copy the **Token ID** and **Token Secret**. +3. Get a token for that user: + - **Standard auth:** log in as them → profile menu → **Edit Profile** → + **API Tokens** → **Create Token**. + - **OIDC auth:** the service user never needs to log in at all. As an admin, + go to Settings → Users → (the user) and use the **API Tokens** section at the + bottom of the edit view. An admin with both *Manage Users* and *Access System + API* can mint tokens on another user's behalf. +4. Copy the **Token ID** and **Token Secret**. The secret is shown once. + +### If you use OIDC group sync + +`OIDC_REMOVE_FROM_GROUPS=true` strips any BookStack role that doesn't match a +group from the ID token, on every login. That will silently revoke your service +user's API access if you assigned its role by hand. Two ways around it: + +- **Never log the service user in.** Create it in the admin UI and mint its token + as an admin (step 3 above). Group sync only runs at login, so a user that never + authenticates through the IdP never gets re-synced. Simplest option. +- **Or model it in the IdP.** Create a group like `bookstack-api`, map it to a + BookStack role that has *Access System API*, and put the service user in it. + Survives logins, and access is managed where the rest of your access lives. + +Either way, a 401 that appears weeks later "for no reason" is almost always this. BookStack rate-limits the API to 180 requests/minute per user by default (`API_REQUESTS_PER_MIN`). @@ -56,25 +83,85 @@ Generate a static token for Claude Code: openssl rand -hex 32 # paste into MCP_STATIC_TOKENS ``` -## 3. Set up GitHub OAuth (needed for claude.ai) +## 3. Set up OAuth (needed for claude.ai) claude.ai will not send custom headers to a connector — it only supports OAuth or -no auth at all. Rather than hand-rolling an OAuth 2.1 server, this project uses -GitHub as the identity provider and restricts access to an allowlist of usernames. +no auth at all. So the server needs an OAuth layer. Rather than hand-rolling an +OAuth 2.1 authorization server, it proxies an existing identity provider. + +### Option A: your own OIDC provider (recommended if you have one) + +If BookStack already uses OIDC — Authelia, Authentik, Keycloak, Zitadel — point +this server at the same IdP. Same login, same groups, no second identity system. + +**Register a new confidential client** for the MCP server. Do not reuse the +BookStack client; the redirect URI differs and they are separate relying parties. + +- Redirect URI: `https://mcp.example.com/auth/callback` +- Grant types: `authorization_code` and `refresh_token` +- PKCE with S256 +- Scopes: `openid profile email offline_access`, plus your groups scope + +Then in `.env`: + +```ini +MCP_AUTH_MODE=oidc+token +MCP_PUBLIC_URL=https://mcp.example.com +OIDC_CONFIG_URL=https://auth.example.com/.well-known/openid-configuration +OIDC_CLIENT_ID=... +OIDC_CLIENT_SECRET=... +MCP_REQUIRED_GROUPS=wiki-users +``` + +Discovery URLs by provider: + +| Provider | `OIDC_CONFIG_URL` | +|---|---| +| Authelia | `https://auth.example.com/.well-known/openid-configuration` | +| Authentik | `https://auth.example.com/application/o//.well-known/openid-configuration` | +| Keycloak | `https://auth.example.com/realms//.well-known/openid-configuration` | +| Zitadel | `https://auth.example.com/.well-known/openid-configuration` | + +**Authelia users:** a ready-made config snippet is in +[`deploy/authelia/configuration.snippet.yml`](deploy/authelia/configuration.snippet.yml). +Three things are easy to miss and each one produces a confusing failure: + +1. **Bypass forward-auth for `mcp.example.com`.** If the host sits behind + Authelia's forward-auth, Claude gets an HTML login page instead of the MCP + endpoint. Claude can't hold session cookies — the MCP server runs its own + OAuth flow instead. Add an `access_control` rule with `policy: 'bypass'`. +2. **Add a claims policy.** Since 4.39, Authelia leaves non-standard claims out + of the ID Token by default, so `groups` won't be there and every tool call + gets denied. Put `groups` and `preferred_username` back via + `claims_policies`. +3. **Set `OIDC_VERIFY_ID_TOKEN=true`.** Authelia issues opaque access tokens, + which can't be verified as JWTs. The ID Token always can. + +Also worth setting a custom `lifespan` with a long `refresh_token` — Authelia's +default is 90 minutes, after which an idle claude.ai connector needs manual +re-authorisation. + +For nested group claims, `OIDC_GROUPS_CLAIM` takes dot-notation, e.g. Keycloak's +`resource_access.bookstack-mcp.roles`. If your provider rejects any of the scopes +above, override the whole list with `OIDC_SCOPES`. + +### Option B: GitHub (if you have no IdP) 1. https://github.com/settings/developers → **New OAuth App** 2. Homepage URL: `https://mcp.example.com` 3. **Authorization callback URL: `https://mcp.example.com/auth/callback`** (must match exactly) -4. Generate a client secret. -5. Put the client ID and secret in `.env`, set `MCP_PUBLIC_URL=https://mcp.example.com`, - and list your GitHub username in `GITHUB_ALLOWED_USERS`. +4. Set `MCP_AUTH_MODE=github+token` plus `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`. -With `MCP_AUTH_MODE=github+token`, both paths work at once: claude.ai does the OAuth -dance, Claude Code sends a static bearer token. +### Who gets in -> Prefer a different IdP? FastMCP ships providers for Google, Auth0, Keycloak, Azure, -> WorkOS, Clerk, Descope, Supabase, and generic OIDC. Swapping `GitHubProvider` in -> `bookstack_mcp/server.py` is a few lines. +`MCP_ALLOWED_USERS` matches on `login` / `preferred_username` / `email` / `sub`, +case-insensitively. `MCP_REQUIRED_GROUPS` requires membership of at least one +listed group. Set at least one of them — leaving both empty means anyone who can +authenticate to your IdP can read your wiki through Claude. The server logs a +warning at startup if you do. + +With `oidc+token` (or `github+token`), both paths work at once: claude.ai does the +OAuth dance, Claude Code sends a static bearer token that bypasses OAuth entirely. ## 4. Deploy @@ -231,8 +318,13 @@ npx @modelcontextprotocol/inspector - Every Claude user shares one BookStack identity. This server does not map GitHub users to individual BookStack accounts, so per-user BookStack permissions don't apply — everyone sees whatever the token user sees. -- Keep `GITHUB_ALLOWED_USERS` populated. An empty allowlist means anyone with a - GitHub account who finds your URL can complete the login. +- Keep `MCP_ALLOWED_USERS` or `MCP_REQUIRED_GROUPS` populated. Empty means anyone + who can authenticate to your IdP gets in — and with a public GitHub app, that is + everyone on GitHub. +- Everyone who connects shares one BookStack identity, so per-user BookStack + permissions do not apply. `MCP_REQUIRED_GROUPS` gates *who may connect*; it does + not scope *what they see*. If different people need different wiki visibility, + run one instance per group with its own BookStack service user. - `MCP_AUTH_MODE=none` is for local testing only. Never expose it publicly. - Wiki content is untrusted input. A page containing instructions aimed at an LLM is a real prompt-injection vector — another reason to start read-only. @@ -245,5 +337,11 @@ npx @modelcontextprotocol/inspector | 403 from BookStack | Token user's role can't see that content | | claude.ai says "Disconnected" right after Connect | Callback URL mismatch, or `/.well-known/*` not proxied to this server | | Claude Code reports a hard failure | An invalid static `Authorization` header does *not* fall back to OAuth — remove the header to let OAuth take over | +| Worked for weeks, now 401 from BookStack | OIDC group sync stripped the service user's role on its last login — see the group sync note in step 1 | +| OAuth completes but every tool call is denied | Username or group claim doesn't match `MCP_ALLOWED_USERS` / `MCP_REQUIRED_GROUPS`; check `journalctl -u bookstack-mcp` for the exact value seen | +| `invalid_scope` from your IdP | The groups scope isn't defined on that client; add it, or drop `MCP_REQUIRED_GROUPS` | +| Token verification fails with an opaque token | Set `OIDC_VERIFY_ID_TOKEN=true` (Authelia, Okta) | +| Claude shows a login page or redirect loop instead of connecting | The MCP host is behind forward-auth; add an Authelia `bypass` rule for it | +| Connector drops after ~90 minutes idle | Authelia's default refresh token lifespan; set a custom `lifespan` on the client | | Connection drops mid-stream | Reverse proxy buffering; keep `flush_interval -1` and disabled timeouts in the Caddyfile | | 429 | BookStack's 180 req/min limit | diff --git a/config.py b/config.py new file mode 100644 index 0000000..b91ec2e --- /dev/null +++ b/config.py @@ -0,0 +1,143 @@ +"""Configuration, loaded from environment variables.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +def _bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _list(name: str) -> list[str]: + raw = os.environ.get(name, "") + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _require(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +@dataclass(frozen=True) +class Settings: + # --- BookStack --- + bookstack_url: str + bookstack_token_id: str + bookstack_token_secret: str + bookstack_verify_tls: bool = True + request_timeout: float = 30.0 + + # --- Behaviour --- + read_only: bool = True + allow_delete: bool = False + max_content_chars: int = 40_000 + + # --- Transport --- + host: str = "0.0.0.0" + port: int = 8080 + mcp_path: str = "/mcp" + + # --- Auth (client -> this MCP server) --- + auth_mode: str = "none" # none | token | github | github+token + static_tokens: list[str] = field(default_factory=list) + public_url: str = "" + github_client_id: str = "" + github_client_secret: str = "" + allowed_hosts: list[str] = field(default_factory=list) + + # --- OIDC (your own IdP: Authentik, Keycloak, Authelia, Zitadel, ...) --- + oidc_config_url: str = "" + oidc_client_id: str = "" + oidc_client_secret: str = "" + oidc_verify_id_token: bool = False + oidc_groups_claim: str = "groups" + oidc_scopes: list[str] = field(default_factory=list) + + # --- Who may use the server (applies to both GitHub and OIDC) --- + allowed_users: list[str] = field(default_factory=list) + required_groups: list[str] = field(default_factory=list) + + @classmethod + def from_env(cls) -> "Settings": + auth_mode = os.environ.get("MCP_AUTH_MODE", "none").strip().lower() + valid_modes = { + "none", "token", + "github", "github+token", + "oidc", "oidc+token", + } + if auth_mode not in valid_modes: + raise RuntimeError( + f"MCP_AUTH_MODE must be one of {'|'.join(sorted(valid_modes))}, got {auth_mode!r}" + ) + + settings = cls( + bookstack_url=_require("BOOKSTACK_URL").rstrip("/"), + bookstack_token_id=_require("BOOKSTACK_TOKEN_ID"), + bookstack_token_secret=_require("BOOKSTACK_TOKEN_SECRET"), + bookstack_verify_tls=_bool("BOOKSTACK_VERIFY_TLS", True), + request_timeout=float(os.environ.get("BOOKSTACK_TIMEOUT", "30")), + read_only=_bool("BOOKSTACK_READ_ONLY", True), + allow_delete=_bool("BOOKSTACK_ALLOW_DELETE", False), + max_content_chars=int(os.environ.get("MCP_MAX_CONTENT_CHARS", "40000")), + host=os.environ.get("MCP_HOST", "0.0.0.0"), + port=int(os.environ.get("MCP_PORT", "8080")), + mcp_path=os.environ.get("MCP_PATH", "/mcp"), + auth_mode=auth_mode, + static_tokens=_list("MCP_STATIC_TOKENS"), + public_url=os.environ.get("MCP_PUBLIC_URL", "").rstrip("/"), + github_client_id=os.environ.get("GITHUB_CLIENT_ID", ""), + github_client_secret=os.environ.get("GITHUB_CLIENT_SECRET", ""), + allowed_hosts=_list("MCP_ALLOWED_HOSTS"), + oidc_config_url=os.environ.get("OIDC_CONFIG_URL", "").strip(), + oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""), + oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""), + oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False), + oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), + oidc_scopes=_list("OIDC_SCOPES"), + # GITHUB_ALLOWED_USERS is the old name, still honoured. + allowed_users=[ + u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS")) + ], + required_groups=[g.lower() for g in _list("MCP_REQUIRED_GROUPS")], + ) + settings.validate() + return settings + + def validate(self) -> None: + if "token" in self.auth_mode and not self.static_tokens: + raise RuntimeError("MCP_AUTH_MODE includes 'token' but MCP_STATIC_TOKENS is empty") + if "github" in self.auth_mode: + if not (self.github_client_id and self.github_client_secret): + raise RuntimeError( + "MCP_AUTH_MODE includes 'github' but GITHUB_CLIENT_ID / " + "GITHUB_CLIENT_SECRET are not set" + ) + if "oidc" in self.auth_mode: + if not self.oidc_config_url: + raise RuntimeError( + "MCP_AUTH_MODE includes 'oidc' but OIDC_CONFIG_URL is not set. " + "This is your provider's discovery document, e.g. " + "https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration" + ) + if not (self.oidc_client_id and self.oidc_client_secret): + raise RuntimeError( + "MCP_AUTH_MODE includes 'oidc' but OIDC_CLIENT_ID / " + "OIDC_CLIENT_SECRET are not set" + ) + if ("github" in self.auth_mode or "oidc" in self.auth_mode) and not self.public_url: + raise RuntimeError( + "MCP_PUBLIC_URL is required for OAuth (e.g. https://mcp.example.com)" + ) + if not self.mcp_path.startswith("/"): + raise RuntimeError("MCP_PATH must start with '/'") + + @property + def api_base(self) -> str: + return f"{self.bookstack_url}/api" diff --git a/configuration.snippet.yml b/configuration.snippet.yml new file mode 100644 index 0000000..b2a9013 --- /dev/null +++ b/configuration.snippet.yml @@ -0,0 +1,116 @@ +# Authelia configuration for the BookStack MCP server. +# +# Merge these blocks into your existing configuration.yml. Tested against +# Authelia 4.39+, where ID Token claims changed (see the claims_policies note). +# +# Generate the client id and secret first: +# +# docker run --rm authelia/authelia:latest \ +# authelia crypto rand --length 72 --charset rfc3986 +# +# docker run --rm authelia/authelia:latest \ +# authelia crypto hash generate pbkdf2 --variant sha512 \ +# --random --random.length 72 --random.charset rfc3986 +# +# The second command prints BOTH a "Random Password" and a "Digest". +# Digest -> client_secret below +# Random Password -> OIDC_CLIENT_SECRET in /etc/bookstack-mcp/env +# Do not put the digest in the MCP server's env file. + +identity_providers: + oidc: + # ----------------------------------------------------------------------- + # 1. Claims policy + # + # Authelia 4.39 stopped putting non-standard claims in the ID Token by + # default. The MCP server reads identity from the ID Token (because + # Authelia issues opaque access tokens), so groups and preferred_username + # have to be put back explicitly. Without this, MCP_REQUIRED_GROUPS will + # never match and every tool call gets denied. + # ----------------------------------------------------------------------- + claims_policies: + mcp_claims: + id_token: + - 'groups' + - 'preferred_username' + - 'email' + - 'email_verified' + - 'name' + + # ----------------------------------------------------------------------- + # 2. Lifespan + # + # Authelia's default refresh_token lifespan is 90m. A claude.ai connector + # that sits idle longer than that has to be re-authorised by hand, which + # gets old fast. A long refresh token with a short access token is the + # right shape for a background integration. + # ----------------------------------------------------------------------- + lifespans: + custom: + mcp: + access_token: '1h' + id_token: '1h' + refresh_token: '30d' + + clients: + - client_id: 'REPLACE_WITH_GENERATED_ID' + client_name: 'BookStack MCP' + client_secret: 'REPLACE_WITH_PBKDF2_DIGEST' + public: false + + # one_factor if you don't want to be prompted for 2FA on reconnect. + authorization_policy: 'two_factor' + + claims_policy: 'mcp_claims' + lifespan: 'mcp' + + # Claude re-authorises on token refresh. Explicit consent every time is + # painful; pre-configured remembers the grant for the given duration. + consent_mode: 'pre-configured' + pre_configured_consent_duration: '1 month' + + require_pkce: true + pkce_challenge_method: 'S256' + + redirect_uris: + - 'https://mcp.example.com/auth/callback' + + # offline_access is what gets you a refresh token. Without it the + # connector dies when the access token expires. + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + - 'offline_access' + + grant_types: + - 'authorization_code' + - 'refresh_token' + + response_types: + - 'code' + + token_endpoint_auth_method: 'client_secret_post' + + # Leaving these as 'none' keeps access tokens opaque, which is why the + # MCP server needs OIDC_VERIFY_ID_TOKEN=true. + access_token_signed_response_alg: 'none' + userinfo_signed_response_alg: 'none' + +# --------------------------------------------------------------------------- +# 3. Access control bypass <-- the one everybody misses +# +# If mcp.example.com is behind Authelia's forward-auth on your reverse proxy, +# Claude will be served an HTML login page instead of the MCP endpoint and the +# connector will fail with a parse error or a redirect loop. Claude cannot do +# cookie-based forward auth; the MCP server runs its own OAuth flow against +# Authelia instead. So the host must bypass forward-auth entirely. +# +# This is not a security hole: the MCP server refuses every unauthenticated +# request itself, and Authelia is still the thing deciding who gets a token. +# --------------------------------------------------------------------------- +access_control: + rules: + - domain: 'mcp.example.com' + policy: 'bypass' diff --git a/example.env b/example.env index 8974c4d..a58ffe0 100644 --- a/example.env +++ b/example.env @@ -41,23 +41,57 @@ MCP_PATH=/mcp # Auth: how Claude authenticates TO this server # none no auth (local testing only) # token static bearer tokens (Claude Code / CLI only) -# github GitHub OAuth (works with claude.ai AND Claude Code) -# github+token both at once <-- recommended +# oidc your own IdP -- Authentik, Keycloak, Authelia, Zitadel, ... +# github GitHub OAuth (use if you have no IdP) +# oidc+token OIDC for claude.ai + static token for Claude Code <-- recommended +# github+token same, with GitHub as the IdP # --------------------------------------------------------------------------- -MCP_AUTH_MODE=github+token +MCP_AUTH_MODE=oidc+token -# Public HTTPS URL of THIS server. Required for GitHub OAuth. +# Public HTTPS URL of THIS server. Required for any OAuth mode. MCP_PUBLIC_URL=https://mcp.example.com # Comma-separated. Generate with: openssl rand -hex 32 MCP_STATIC_TOKENS= -# GitHub OAuth App (https://github.com/settings/developers) -# Authorization callback URL must be exactly: +# --- Option A: your own OIDC provider ------------------------------------- +# The discovery document URL. Examples: +# Authentik https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration +# Keycloak https://auth.example.com/realms/main/.well-known/openid-configuration +# Authelia https://auth.example.com/.well-known/openid-configuration +# Zitadel https://auth.example.com/.well-known/openid-configuration +OIDC_CONFIG_URL= + +# Register a NEW confidential client in your IdP for this server -- do not +# reuse the BookStack client. Redirect URI must be exactly: # https://mcp.example.com/auth/callback +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= + +# Set true if your IdP issues opaque (non-JWT) access tokens; the ID token is +# then verified instead. Authelia and Okta commonly need this. Keycloak and +# Authentik issue JWTs, so leave it false. +OIDC_VERIFY_ID_TOKEN=false + +# Claim holding the user's groups. Supports dot-notation for nested claims, +# e.g. Keycloak's resource_access.bookstack-mcp.roles +OIDC_GROUPS_CLAIM=groups + +# Override the requested scopes entirely, if your provider needs something +# specific. Default: openid, profile, email, offline_access (+ groups claim +# when MCP_REQUIRED_GROUPS is set). +OIDC_SCOPES= + +# --- Option B: GitHub instead of your own IdP ----------------------------- +# https://github.com/settings/developers +# Callback URL: https://mcp.example.com/auth/callback GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Only these GitHub usernames may use the server. Leave empty to allow anyone -# who can complete the GitHub login (almost never what you want). -GITHUB_ALLOWED_USERS=your-github-username +# --- Who may use the server (applies to whichever provider you chose) ------ +# Matched against login / preferred_username / email / sub. Case-insensitive. +MCP_ALLOWED_USERS= + +# And/or require membership of at least one of these groups. +# Leave BOTH empty and anyone who can log in to your IdP gets in. +MCP_REQUIRED_GROUPS=wiki-users diff --git a/server.py b/server.py index dea4863..8e45ef0 100644 --- a/server.py +++ b/server.py @@ -81,10 +81,21 @@ def create_server(settings: Settings) -> FastMCP: lifespan=lifespan, ) - if settings.github_allowed_users: - from .authz import GitHubAllowlistMiddleware + if settings.allowed_users or settings.required_groups: + from .authz import IdentityAllowlistMiddleware - mcp.add_middleware(GitHubAllowlistMiddleware(settings.github_allowed_users)) + mcp.add_middleware( + IdentityAllowlistMiddleware( + allowed_users=settings.allowed_users, + required_groups=settings.required_groups, + groups_claim=settings.oidc_groups_claim, + ) + ) + elif settings.auth_mode not in {"none", "token"}: + logger.warning( + "OAuth is enabled but MCP_ALLOWED_USERS and MCP_REQUIRED_GROUPS are both " + "empty: anyone who can log in to your identity provider can use this server." + ) # ------------------------------------------------------------------ read @@ -351,22 +362,51 @@ def _build_auth(settings: Settings): } ) - if "github" not in mode: + oauth = None + + if "github" in mode: + from fastmcp.server.auth.providers.github import GitHubProvider + + oauth = GitHubProvider( + client_id=settings.github_client_id, + client_secret=settings.github_client_secret, + base_url=settings.public_url, + redirect_path="/auth/callback", + required_scopes=["read:user"], + ) + + elif "oidc" in mode: + from fastmcp.server.auth.oidc_proxy import OIDCProxy + + # OIDC_SCOPES overrides this entirely when a provider needs something + # specific. Otherwise: identify the user, ask for groups only when group + # checks are configured (some providers reject unknown scopes), and + # request offline_access so the connection survives token expiry. + if settings.oidc_scopes: + scopes = settings.oidc_scopes + else: + scopes = ["openid", "profile", "email", "offline_access"] + if settings.required_groups: + scopes.insert(3, settings.oidc_groups_claim) + + oauth = OIDCProxy( + config_url=settings.oidc_config_url, + client_id=settings.oidc_client_id, + client_secret=settings.oidc_client_secret, + base_url=settings.public_url, + redirect_path="/auth/callback", + required_scopes=scopes, + # Some providers (Authelia, Okta) issue opaque access tokens that + # can't be validated as JWTs. Setting OIDC_VERIFY_ID_TOKEN=true + # verifies the ID token instead, which is always a signed JWT. + verify_id_token=settings.oidc_verify_id_token, + ) + + if oauth is None: return verifier - - from fastmcp.server.auth.providers.github import GitHubProvider - - github = GitHubProvider( - client_id=settings.github_client_id, - client_secret=settings.github_client_secret, - base_url=settings.public_url, - redirect_path="/auth/callback", - required_scopes=["read:user"], - ) - if verifier is None: - return github + return oauth from fastmcp.server.auth import MultiAuth - return MultiAuth(server=github, verifiers=[verifier], base_url=settings.public_url) + return MultiAuth(server=oauth, verifiers=[verifier], base_url=settings.public_url)