Add require_roles auth check (#4656)

* Add require_roles auth check

* Make role docs runnable standalone and fully annotated

* Treat a scalar role claim as one role; correct step-up docs

* Add v4 version badge to require_roles docs
This commit is contained in:
Jeremiah Lowin 2026-07-27 12:36:58 -04:00 committed by GitHub
commit 920cb47778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 319 additions and 6 deletions

View file

@ -58,6 +58,75 @@ def read_write_operation() -> str:
return "Read/write action completed"
```
### require_roles
<VersionBadge version="4.0.0" />
Scopes are standardized, so `require_scopes` works the same everywhere. Roles and groups are not part of OIDC, so every identity provider puts them under a different claim. `require_roles` handles the comparison and takes an `extract` callable that tells it where to look.
```python
from fastmcp import FastMCP
from fastmcp.server.auth import require_roles
def keycloak_roles(claims: dict) -> list[str]:
return claims["realm_access"]["roles"]
mcp = FastMCP("Role Server")
@mcp.tool(auth=require_roles("admin", extract=keycloak_roles))
def admin_operation() -> str:
"""Requires the 'admin' role."""
return "Admin action completed"
@mcp.tool(auth=require_roles("admin", "auditor", extract=keycloak_roles))
def audited_admin_operation() -> str:
"""Requires both the 'admin' AND 'auditor' roles."""
return "Audited admin action"
```
Multiple roles are required together, matching `require_scopes`. A token whose claims lack the path entirely is denied rather than raising, so the extractor can index directly.
Keeping the claim path at the call site means any provider works, including ones with unusual shapes. Common locations:
| Provider | Extractor |
| --- | --- |
| Keycloak | `lambda c: c["realm_access"]["roles"]` |
| Microsoft Entra | `lambda c: c["roles"]` |
| AWS Cognito | `lambda c: c["cognito:groups"]` |
| Auth0 | `lambda c: c["permissions"]` |
Verify the claim against your own tenant before relying on it. Auth0's namespaced custom claims are configured per tenant, and Entra emits `roles` or `groups` depending on the app manifest.
<Note>
`require_roles` cannot signal a scope shortfall, because OAuth has no way to request a role. A role denial surfaces as a plain `AuthorizationError` rather than one of the `insufficient_scope` challenges described in [Signaling Scope Shortfalls](#signaling-scope-shortfalls), and it suppresses any scope shortfall raised alongside it — a caller blocked by their role should not be told to go obtain a scope that would not help them. Combining `require_roles` with `require_scopes` is otherwise fine: whenever the role check passes, a scope shortfall is reported as usual.
</Note>
### Checking Other Claims
`require_roles` is a convenience for the common case. `AccessToken.claims` holds every claim from the token, so gating on anything else needs no special API — just an auth check that reads it.
```python
from fastmcp import FastMCP
from fastmcp.server.auth import AuthCheck, AuthContext
mcp = FastMCP("Claim Server")
def require_tenant(tenant_id: str) -> AuthCheck:
"""Require the token to come from a specific tenant."""
def check(ctx: AuthContext) -> bool:
if ctx.token is None:
return False
return ctx.token.claims.get("tid") == tenant_id
return check
@mcp.tool(auth=require_tenant("acme"))
def tenant_operation() -> str:
"""Only callable by tokens issued for the acme tenant."""
return "Tenant action completed"
```
The same caveat applies: a check like this is opaque, so it suppresses scope disclosure for its siblings.
### restrict_tag
Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected.
@ -107,7 +176,7 @@ Any callable that accepts `AuthContext` and returns `bool` can serve as an auth
```python
from fastmcp import FastMCP
from fastmcp.server.auth import AuthContext
from fastmcp.server.auth import AuthCheck, AuthContext
mcp = FastMCP("Custom Auth Server")
@ -117,7 +186,7 @@ def require_premium_user(ctx: AuthContext) -> bool:
return False
return ctx.token.claims.get("premium", False) is True
def require_access_level(minimum_level: int):
def require_access_level(minimum_level: int) -> AuthCheck:
"""Factory function for level-based authorization."""
def check(ctx: AuthContext) -> bool:
if ctx.token is None:
@ -417,6 +486,7 @@ from fastmcp.server.auth import (
AuthContext, # Context with .token, .component
AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool]
require_scopes, # Built-in: requires specific scopes
require_roles, # Built-in: requires roles read from token claims
restrict_tag, # Built-in: tag-based scope requirements
run_auth_checks, # Utility: run checks with AND logic
)

View file

@ -11,6 +11,7 @@ from .auth import (
from .authorization import (
AuthCheck,
AuthContext,
require_roles,
require_scopes,
restrict_tag,
run_auth_checks,
@ -75,6 +76,7 @@ __all__ = [
"RemoteAuthProvider",
"StaticTokenVerifier",
"TokenVerifier",
"require_roles",
"require_scopes",
"restrict_tag",
"run_auth_checks",

View file

@ -3,6 +3,7 @@
from fastmcp.utilities.authorization import (
AuthCheck,
AuthContext,
require_roles,
require_scopes,
restrict_tag,
run_auth_checks,
@ -13,6 +14,7 @@ from fastmcp.utilities.authorization import (
__all__ = [
"AuthCheck",
"AuthContext",
"require_roles",
"require_scopes",
"restrict_tag",
"run_auth_checks",

View file

@ -9,9 +9,9 @@ from __future__ import annotations
import inspect
import logging
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
from fastmcp.exceptions import AuthorizationError
@ -104,11 +104,96 @@ class _RestrictTag(_ScopeAwareCheck):
return set(self.required_scopes) - set(ctx.token.scopes)
class _RequireRoles:
"""Callable auth check requiring all of a fixed set of roles.
Deliberately not a :class:`_ScopeAwareCheck`. Roles are not scopes and
cannot be requested through OAuth, so a shortfall has no spec-correct
step-up representation.
"""
def __init__(
self,
roles: tuple[str, ...],
extract: Callable[[dict[str, Any]], Iterable[str]],
) -> None:
self.required_roles: frozenset[str] = frozenset(roles)
self._extract = extract
def __call__(self, ctx: AuthContext) -> bool:
if ctx.token is None:
return False
try:
extracted = self._extract(ctx.token.claims)
# A provider that stores a single role as a bare string satisfies
# `Iterable[str]`, but iterating it yields characters: "admin"
# would deny an "admin" requirement and grant an "a" one. Treat a
# string as the single role it plainly means.
if isinstance(extracted, str):
extracted = [extracted]
granted = set(extracted)
except (KeyError, IndexError, TypeError):
# A caller whose token simply lacks the claim is an ordinary
# denial, not a broken check: return False rather than letting
# `_evaluate_check` log a warning on every unauthorized request.
return False
return self.required_roles.issubset(granted)
def require_scopes(*scopes: str) -> AuthCheck:
"""Require all of the given OAuth scopes."""
return _RequireScopes(scopes)
def require_roles(
*roles: str,
extract: Callable[[dict[str, Any]], Iterable[str]],
) -> AuthCheck:
"""Require all of the given roles, read from the token's claims.
Roles and groups are not part of OIDC, so every identity provider puts them
somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft
Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom
claim on Auth0. `extract` receives the token's claims and returns the
caller's roles, which keeps that provider-specific knowledge at the call
site instead of guessing it here.
```python
from fastmcp.server.auth import require_roles
keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"])
cognito = require_roles("admins", extract=lambda c: c["cognito:groups"])
```
A token missing the claim entirely is denied rather than treated as an
error, so `extract` may index into the claims without guarding. An
extractor returning a bare string is treated as one role, since a provider
that stores a single role as a scalar is common.
Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no
way to request a role, so there is no `insufficient_scope` challenge to
emit. A role denial is therefore reported as a plain `AuthorizationError`,
and it suppresses any scope shortfall alongside it a caller blocked by
their role must not be told to go obtain a scope that would not help.
Scope shortfalls are still reported normally whenever the role check
passes.
Args:
*roles: Roles the caller must hold. All are required (AND logic).
extract: Callable mapping the token's claims to the caller's roles.
Raises:
ValueError: If no roles are given, which would allow any authenticated
caller and is more likely a mistake than an intent.
"""
if not roles:
raise ValueError(
"require_roles() needs at least one role; a check with no roles "
"would admit any authenticated caller."
)
return _RequireRoles(roles, extract)
def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck:
"""Require scopes when the accessed component has a specific tag."""
return _RestrictTag(tag, scopes)

View file

@ -12,6 +12,7 @@ from fastmcp.exceptions import AuthorizationError, InsufficientScopeError
from fastmcp.server.auth import (
AccessToken,
AuthContext,
require_roles,
require_scopes,
restrict_tag,
run_auth_checks,
@ -19,6 +20,7 @@ from fastmcp.server.auth import (
from fastmcp.server.middleware import AuthMiddleware
from fastmcp.server.transforms import ToolTransform
from fastmcp.tools.tool_transform import ToolTransformConfig, TransformedTool
from fastmcp.utilities.authorization import scope_requirements
from fastmcp.utilities.versions import VersionSpec
# =============================================================================
@ -26,14 +28,17 @@ from fastmcp.utilities.versions import VersionSpec
# =============================================================================
def make_token(scopes: list[str] | None = None) -> AccessToken:
def make_token(
scopes: list[str] | None = None,
claims: dict | None = None,
) -> AccessToken:
"""Create a test access token."""
return AccessToken(
token="test-token",
client_id="test-client",
scopes=scopes or [],
expires_at=None,
claims={},
claims=claims or {},
)
@ -86,6 +91,155 @@ class TestRequireScopes:
assert check(ctx) is False
# =============================================================================
# Tests for require_roles
# =============================================================================
KEYCLOAK = {"realm_access": {"roles": ["admin", "viewer"]}}
def keycloak_roles(claims: dict) -> list[str]:
return claims["realm_access"]["roles"]
class TestRequireRoles:
@pytest.mark.parametrize(
"claims, extract",
[
(KEYCLOAK, keycloak_roles),
({"roles": ["admin"]}, lambda c: c["roles"]),
({"cognito:groups": ["admin"]}, lambda c: c["cognito:groups"]),
({"permissions": ["admin"]}, lambda c: c["permissions"]),
(
{"https://app.example.com/roles": ["admin"]},
lambda c: c["https://app.example.com/roles"],
),
],
)
def test_reads_roles_from_provider_specific_claim(self, claims, extract):
ctx = AuthContext(token=make_token(claims=claims), component=make_tool())
assert require_roles("admin", extract=extract)(ctx) is True
def test_requires_all_roles(self):
ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool())
check = require_roles("admin", "viewer", extract=keycloak_roles)
assert check(ctx) is True
def test_denies_when_one_role_missing(self):
ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool())
check = require_roles("admin", "auditor", extract=keycloak_roles)
assert check(ctx) is False
def test_denies_without_token(self):
ctx = AuthContext(token=None, component=make_tool())
assert require_roles("admin", extract=keycloak_roles)(ctx) is False
@pytest.mark.parametrize(
"claims",
[{}, {"realm_access": {}}, {"realm_access": None}, {"realm_access": []}],
)
def test_denies_when_claim_absent_or_malformed(self, claims):
"""A token without the claim is an ordinary denial, not a broken check."""
ctx = AuthContext(token=make_token(claims=claims), component=make_tool())
assert require_roles("admin", extract=keycloak_roles)(ctx) is False
def test_rejects_empty_role_list(self):
"""A check with no roles would admit any authenticated caller."""
with pytest.raises(ValueError, match="at least one role"):
require_roles(extract=keycloak_roles)
def test_is_opaque_to_scope_shortfall(self):
"""Roles cannot be requested via OAuth, so they yield no step-up."""
ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool())
check = require_roles("auditor", extract=keycloak_roles)
assert scope_requirements(check, ctx) is None
def test_suppresses_shortfall_disclosure_of_sibling_scope_checks(self):
"""One opaque check withholds the whole list's scope requirements."""
token = make_token(scopes=["read"], claims=KEYCLOAK)
ctx = AuthContext(token=token, component=make_tool())
checks = [
require_scopes("write"),
require_roles("admin", extract=keycloak_roles),
]
assert scope_requirements(checks, ctx) is None
assert scope_requirements([require_scopes("write")], ctx) == ["write"]
@pytest.mark.parametrize(
"required, expected",
[("admin", True), ("a", False), ("dmin", False)],
)
def test_scalar_role_claim_is_one_role(self, required: str, expected: bool):
"""A provider storing one role as a string must not be iterated.
`str` satisfies `Iterable[str]`, so a bare "admin" would otherwise
become the character set {a, d, m, i, n} denying the "admin" it
plainly grants and granting any single character it contains.
"""
ctx = AuthContext(
token=make_token(claims={"role": "admin"}), component=make_tool()
)
check = require_roles(required, extract=lambda c: c["role"])
assert check(ctx) is expected
async def test_role_denial_suppresses_scope_challenge(self):
"""A caller blocked by their role is not told to obtain a scope."""
mcp = FastMCP(
middleware=[
AuthMiddleware(
auth=[
require_scopes("api"),
require_roles("admin", extract=keycloak_roles),
]
)
]
)
@mcp.tool
def t() -> str:
return "ok"
token = make_token(scopes=["read"], claims={"realm_access": {"roles": ["v"]}})
tok = set_token(token)
try:
with pytest.raises(AuthorizationError) as exc_info:
await mcp.call_tool("t", {})
finally:
auth_context_var.reset(tok)
assert not isinstance(exc_info.value, InsufficientScopeError)
async def test_passing_role_still_allows_scope_challenge(self):
"""Mixing the two checks does not disable step-up on its own."""
mcp = FastMCP(
middleware=[
AuthMiddleware(
auth=[
require_scopes("api"),
require_roles("admin", extract=keycloak_roles),
]
)
]
)
@mcp.tool
def t() -> str:
return "ok"
token = make_token(
scopes=["read"], claims={"realm_access": {"roles": ["admin"]}}
)
tok = set_token(token)
try:
with pytest.raises(InsufficientScopeError) as exc_info:
await mcp.call_tool("t", {})
finally:
auth_context_var.reset(tok)
assert exc_info.value.required_scopes == ["api"]
# =============================================================================
# Tests for restrict_tag
# =============================================================================