Skip to content

Commit 275d6d6

Browse files
committed
feat: authorize services by OIDC groups
1 parent 753fb90 commit 275d6d6

7 files changed

Lines changed: 117 additions & 11 deletions

File tree

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ MCP_AUTH_OIDC_CLIENT_ID=
1717
MCP_AUTH_OIDC_CLIENT_SECRET=
1818
MCP_AUTH_OIDC_REDIRECT_PATH=/auth/callback
1919
MCP_AUTH_OIDC_JWT_SIGNING_KEY=
20-
MCP_AUTH_REQUIRED_SCOPES=
20+
# Request groups from Pocket ID to use service authorization.
21+
MCP_AUTH_REQUIRED_SCOPES=openid,profile,groups
22+
# Claim holding a user's groups or roles. Pocket ID uses groups by default.
23+
MCP_AUTH_GROUP_CLAIM=groups
24+
# JSON mapping: service keys (gitea, pocket_id) to Pocket ID OIDC group/role names.
25+
# A mapped service requires membership of any listed Pocket ID group or role.
26+
# MCP_SERVICE_GROUPS={"gitea":["mcp-gitea"],"pocket_id":["mcp-pocket-id"]}
27+
MCP_SERVICE_GROUPS={}
2128

2229
# Use jwt when clients already have a Pocket ID JWT and only need token verification.
2330
# For Pocket ID, issuer defaults to POCKET_ID_URL.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ MCP_AUTH_BASE_URL=https://labmcp.example.com
4747
MCP_AUTH_OIDC_CLIENT_ID=<pocket-id-client-id>
4848
MCP_AUTH_OIDC_CLIENT_SECRET=<pocket-id-client-secret>
4949
MCP_AUTH_OIDC_JWT_SIGNING_KEY=<stable-random-secret>
50-
MCP_AUTH_REQUIRED_SCOPES=openid,profile
50+
MCP_AUTH_REQUIRED_SCOPES=openid,profile,groups
5151
```
5252

5353
By default, `MCP_AUTH_OIDC_CONFIG_URL` is derived as `<POCKET_ID_URL>/.well-known/openid-configuration`, and the callback path is `/auth/callback`. Set them explicitly if your Pocket ID issuer or reverse proxy path differs from `POCKET_ID_URL`.
@@ -67,6 +67,20 @@ MCP_AUTH_JWT_AUDIENCE=<pocket-id-client-id>
6767

6868
In `jwt` mode, `MCP_AUTH_JWT_ISSUER` defaults to `POCKET_ID_URL`, and `MCP_AUTH_JWT_JWKS_URI` defaults to `<issuer>/.well-known/jwks.json`. If you configure `MCP_AUTH_REQUIRED_SCOPES`, provide a comma-separated list of scopes that must be present in the token.
6969

70+
### Service access by Pocket ID group or role
71+
72+
Tools can be made visible only to users in specific Pocket ID groups. Request Pocket ID's `groups` scope and map each service to one or more permitted groups:
73+
74+
```sh
75+
MCP_AUTH_REQUIRED_SCOPES=openid,profile,groups
76+
MCP_AUTH_GROUP_CLAIM=groups
77+
MCP_SERVICE_GROUPS={"gitea":["mcp-gitea"],"pocket_id":["mcp-pocket-id"]}
78+
```
79+
80+
Membership of any group listed for a service permits access to that service's tools. The Gitea tools use the `gitea` service key; all Pocket ID tools use `pocket_id`. Once `MCP_SERVICE_GROUPS` is non-empty, any omitted service is denied. Leave it as `{}` only when every authenticated user should have access to all services.
81+
82+
Pocket ID normally provides groups in the `groups` claim. To authorize from a custom role claim instead, configure that custom claim on the relevant Pocket ID groups, set `MCP_AUTH_GROUP_CLAIM` to its name, and use its values in `MCP_SERVICE_GROUPS`.
83+
7084
## CI and code quality
7185

7286
Pull requests run `.gitea/workflows/ci.yml`, which installs dependencies with `uv`, runs Ruff, executes tests through Coverage.py, writes `testresults.xml` and `coverage.xml`, prints the coverage summary, compiles the Python sources, and builds a Docker image.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "A unified Model Context Protocol server for a home lab"
55
readme = "README.md"
66
requires-python = ">=3.14"
77
dependencies = [
8-
"fastmcp>=3.0,<4.0",
8+
"fastmcp>=3.2.1,<4.0",
99
"httpx>=0.27,<1.0",
1010
"pydantic-settings>=2.0,<3.0",
1111
]

src/labmcp/authorization.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from collections.abc import Callable, Mapping, Sequence
2+
from typing import Any
3+
4+
from .config import Settings
5+
6+
AuthCheck = Callable[[Any], bool]
7+
8+
9+
def require_service_access(service: str, settings: Settings) -> AuthCheck | None:
10+
"""Create a per-service authorization check from the configured claim mapping.
11+
12+
Without a mapping, every authenticated user may use every service. Once a mapping
13+
is configured, each service requires membership of at least one listed group.
14+
"""
15+
if not settings.mcp_service_groups:
16+
return None
17+
allowed_groups = settings.mcp_service_groups.get(service, [])
18+
19+
def check(context: Any) -> bool:
20+
token = context.token
21+
if token is None:
22+
return False
23+
user_groups = _claim_values(token.claims, settings.mcp_auth_group_claim)
24+
return bool(user_groups.intersection(allowed_groups))
25+
26+
return check
27+
28+
29+
def _claim_values(claims: Mapping[str, Any], claim_name: str) -> set[str]:
30+
value = claims.get(claim_name)
31+
if isinstance(value, str):
32+
return {value}
33+
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
34+
return {item for item in value if isinstance(item, str)}
35+
return set()

src/labmcp/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import lru_cache
22
from typing import Literal
33

4-
from pydantic import SecretStr
4+
from pydantic import Field, SecretStr
55
from pydantic_settings import BaseSettings, SettingsConfigDict
66

77

@@ -26,6 +26,8 @@ class Settings(BaseSettings):
2626
mcp_auth_jwt_audience: str | None = None
2727
mcp_auth_required_scopes: str | None = None
2828
mcp_auth_jwt_required_scopes: str | None = None
29+
mcp_auth_group_claim: str = "groups"
30+
mcp_service_groups: dict[str, list[str]] = Field(default_factory=dict)
2931
mcp_auth_oidc_config_url: str | None = None
3032
mcp_auth_oidc_client_id: str | None = None
3133
mcp_auth_oidc_client_secret: SecretStr | None = None

src/labmcp/server.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from fastmcp import FastMCP
44

55
from .auth import create_auth_provider, ensure_network_transport_is_authenticated
6+
from .authorization import require_service_access
67
from .clients import gitea_client, pocket_id_client
78
from .config import get_settings
89
from .version import get_version
@@ -11,13 +12,17 @@
1112
mcp = FastMCP(f"Home Lab ({get_version()})", auth=create_auth_provider(_settings))
1213

1314

15+
def _service_auth(service: str):
16+
return require_service_access(service, _settings)
17+
18+
1419
@mcp.tool()
1520
def labmcp_get_version() -> str:
1621
"""Return the running labmcp package version."""
1722
return get_version()
1823

1924

20-
@mcp.tool()
25+
@mcp.tool(auth=_service_auth("gitea"))
2126
async def gitea_list_repositories(
2227
page: int = 1,
2328
limit: int = 50,
@@ -34,13 +39,13 @@ async def gitea_list_repositories(
3439
return result
3540

3641

37-
@mcp.tool()
42+
@mcp.tool(auth=_service_auth("gitea"))
3843
async def gitea_get_repository(owner: str, repo: str) -> dict[str, Any]:
3944
"""Get metadata for one Gitea repository."""
4045
return await gitea_client(get_settings()).request("GET", f"/api/v1/repos/{owner}/{repo}")
4146

4247

43-
@mcp.tool()
48+
@mcp.tool(auth=_service_auth("gitea"))
4449
async def gitea_list_issues(
4550
owner: str,
4651
repo: str,
@@ -60,7 +65,7 @@ async def gitea_list_issues(
6065
)
6166

6267

63-
@mcp.tool()
68+
@mcp.tool(auth=_service_auth("gitea"))
6469
async def gitea_create_issue(owner: str, repo: str, title: str, body: str = "") -> dict[str, Any]:
6570
"""Create an issue in a Gitea repository."""
6671
if not title.strip():
@@ -70,22 +75,22 @@ async def gitea_create_issue(owner: str, repo: str, title: str, body: str = "")
7075
)
7176

7277

73-
@mcp.tool()
78+
@mcp.tool(auth=_service_auth("pocket_id"))
7479
async def pocket_id_openid_configuration() -> dict[str, Any]:
7580
"""Read Pocket ID's OpenID Connect discovery document."""
7681
return await pocket_id_client(get_settings()).request(
7782
"GET", "/.well-known/openid-configuration"
7883
)
7984

8085

81-
@mcp.tool()
86+
@mcp.tool(auth=_service_auth("pocket_id"))
8287
async def pocket_id_health() -> Any:
8388
"""Check Pocket ID health using POCKET_ID_HEALTH_PATH (default: /api/health)."""
8489
settings = get_settings()
8590
return await pocket_id_client(settings).request("GET", settings.pocket_id_health_path)
8691

8792

88-
@mcp.tool()
93+
@mcp.tool(auth=_service_auth("pocket_id"))
8994
async def pocket_id_get_json(path: str) -> Any:
9095
"""GET a JSON endpoint on Pocket ID, useful for deployments with an enabled API."""
9196
if not path.startswith("/") or path.startswith("//"):

tests/test_authorization.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from types import SimpleNamespace
2+
3+
from labmcp.authorization import require_service_access
4+
from labmcp.config import Settings
5+
6+
7+
def _context(claims: dict[str, object]) -> SimpleNamespace:
8+
return SimpleNamespace(token=SimpleNamespace(claims=claims))
9+
10+
11+
def test_empty_mapping_does_not_add_a_group_restriction() -> None:
12+
settings = Settings()
13+
14+
assert require_service_access("pocket_id", settings) is None
15+
16+
17+
def test_unmapped_service_is_denied_when_group_mapping_is_enabled() -> None:
18+
settings = Settings(mcp_service_groups={"gitea": ["mcp-gitea"]})
19+
check = require_service_access("pocket_id", settings)
20+
21+
assert check is not None
22+
assert check(_context({"groups": ["mcp-pocket-id"]})) is False
23+
24+
25+
def test_service_group_access_requires_an_allowed_group() -> None:
26+
settings = Settings(mcp_service_groups={"gitea": ["mcp-gitea", "mcp-admin"]})
27+
check = require_service_access("gitea", settings)
28+
29+
assert check is not None
30+
assert check(_context({"groups": ["mcp-gitea"]})) is True
31+
assert check(_context({"groups": ["mcp-pocket-id"]})) is False
32+
assert check(SimpleNamespace(token=None)) is False
33+
34+
35+
def test_service_group_access_supports_a_custom_role_claim() -> None:
36+
settings = Settings(
37+
mcp_auth_group_claim="roles",
38+
mcp_service_groups={"pocket_id": ["administrator"]},
39+
)
40+
check = require_service_access("pocket_id", settings)
41+
42+
assert check is not None
43+
assert check(_context({"roles": "administrator"})) is True

0 commit comments

Comments
 (0)