Skip to content

Commit 15b40b2

Browse files
authored
Merge pull request #2 from andylim-duo/feature/multi-tenant-auth-tokens
feat(auth): add tenant_id field to authentication token models
2 parents eaf971c + cf3faa7 commit 15b40b2

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

src/mcp/server/auth/provider.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ class AuthorizationCode(BaseModel):
2525
redirect_uri: AnyUrl
2626
redirect_uri_provided_explicitly: bool
2727
resource: str | None = None # RFC 8707 resource indicator
28+
tenant_id: str | None = None # Tenant this code belongs to
2829

2930

3031
class RefreshToken(BaseModel):
3132
token: str
3233
client_id: str
3334
scopes: list[str]
3435
expires_at: int | None = None
36+
tenant_id: str | None = None # Tenant this token belongs to
3537

3638

3739
class AccessToken(BaseModel):
@@ -40,6 +42,7 @@ class AccessToken(BaseModel):
4042
scopes: list[str]
4143
expires_at: int | None = None
4244
resource: str | None = None # RFC 8707 resource indicator
45+
tenant_id: str | None = None # Tenant this token belongs to
4346

4447

4548
RegistrationErrorCode = Literal[
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Tests for multi-tenancy support in authentication token models."""
2+
3+
import pytest
4+
from pydantic import AnyUrl
5+
6+
from mcp.server.auth.provider import AccessToken, AuthorizationCode, RefreshToken
7+
8+
9+
def test_authorization_code_with_tenant_id():
10+
"""Test AuthorizationCode creation with tenant_id."""
11+
code = AuthorizationCode(
12+
code="test_code",
13+
scopes=["read", "write"],
14+
expires_at=1234567890.0,
15+
client_id="test_client",
16+
code_challenge="challenge123",
17+
redirect_uri=AnyUrl("http://localhost:8000/callback"),
18+
redirect_uri_provided_explicitly=True,
19+
tenant_id="tenant-abc",
20+
)
21+
assert code.tenant_id == "tenant-abc"
22+
assert code.code == "test_code"
23+
assert code.scopes == ["read", "write"]
24+
25+
26+
def test_authorization_code_without_tenant_id():
27+
"""Test AuthorizationCode backward compatibility without tenant_id."""
28+
code = AuthorizationCode(
29+
code="test_code",
30+
scopes=["read"],
31+
expires_at=1234567890.0,
32+
client_id="test_client",
33+
code_challenge="challenge123",
34+
redirect_uri=AnyUrl("http://localhost:8000/callback"),
35+
redirect_uri_provided_explicitly=False,
36+
)
37+
assert code.tenant_id is None
38+
39+
40+
def test_authorization_code_serialization_with_tenant_id():
41+
"""Test AuthorizationCode serialization includes tenant_id."""
42+
code = AuthorizationCode(
43+
code="test_code",
44+
scopes=["read"],
45+
expires_at=1234567890.0,
46+
client_id="test_client",
47+
code_challenge="challenge123",
48+
redirect_uri=AnyUrl("http://localhost:8000/callback"),
49+
redirect_uri_provided_explicitly=True,
50+
tenant_id="tenant-xyz",
51+
)
52+
data = code.model_dump()
53+
assert data["tenant_id"] == "tenant-xyz"
54+
55+
# Verify deserialization
56+
restored = AuthorizationCode.model_validate(data)
57+
assert restored.tenant_id == "tenant-xyz"
58+
59+
60+
def test_refresh_token_with_tenant_id():
61+
"""Test RefreshToken creation with tenant_id."""
62+
token = RefreshToken(
63+
token="refresh_token_123",
64+
client_id="test_client",
65+
scopes=["read", "write"],
66+
tenant_id="tenant-abc",
67+
)
68+
assert token.tenant_id == "tenant-abc"
69+
assert token.token == "refresh_token_123"
70+
71+
72+
def test_refresh_token_without_tenant_id():
73+
"""Test RefreshToken backward compatibility without tenant_id."""
74+
token = RefreshToken(
75+
token="refresh_token_123",
76+
client_id="test_client",
77+
scopes=["read"],
78+
)
79+
assert token.tenant_id is None
80+
81+
82+
def test_refresh_token_serialization_with_tenant_id():
83+
"""Test RefreshToken serialization includes tenant_id."""
84+
token = RefreshToken(
85+
token="refresh_token_123",
86+
client_id="test_client",
87+
scopes=["read"],
88+
expires_at=1234567890,
89+
tenant_id="tenant-xyz",
90+
)
91+
data = token.model_dump()
92+
assert data["tenant_id"] == "tenant-xyz"
93+
94+
# Verify deserialization
95+
restored = RefreshToken.model_validate(data)
96+
assert restored.tenant_id == "tenant-xyz"
97+
98+
99+
def test_access_token_with_tenant_id():
100+
"""Test AccessToken creation with tenant_id."""
101+
token = AccessToken(
102+
token="access_token_123",
103+
client_id="test_client",
104+
scopes=["read", "write"],
105+
tenant_id="tenant-abc",
106+
)
107+
assert token.tenant_id == "tenant-abc"
108+
assert token.token == "access_token_123"
109+
110+
111+
def test_access_token_without_tenant_id():
112+
"""Test AccessToken backward compatibility without tenant_id."""
113+
token = AccessToken(
114+
token="access_token_123",
115+
client_id="test_client",
116+
scopes=["read"],
117+
)
118+
assert token.tenant_id is None
119+
120+
121+
def test_access_token_serialization_with_tenant_id():
122+
"""Test AccessToken serialization includes tenant_id."""
123+
token = AccessToken(
124+
token="access_token_123",
125+
client_id="test_client",
126+
scopes=["read"],
127+
expires_at=1234567890,
128+
resource="https://api.example.com",
129+
tenant_id="tenant-xyz",
130+
)
131+
data = token.model_dump()
132+
assert data["tenant_id"] == "tenant-xyz"
133+
134+
# Verify deserialization
135+
restored = AccessToken.model_validate(data)
136+
assert restored.tenant_id == "tenant-xyz"
137+
138+
139+
def test_access_token_with_resource_and_tenant_id():
140+
"""Test AccessToken with both resource (RFC 8707) and tenant_id."""
141+
token = AccessToken(
142+
token="access_token_123",
143+
client_id="test_client",
144+
scopes=["read"],
145+
resource="https://api.example.com",
146+
tenant_id="tenant-abc",
147+
)
148+
assert token.resource == "https://api.example.com"
149+
assert token.tenant_id == "tenant-abc"
150+
151+
152+
@pytest.mark.parametrize(
153+
"tenant_id",
154+
[
155+
"tenant-123",
156+
"org_abc_def",
157+
"a" * 100, # Long tenant ID
158+
"tenant-with-dashes",
159+
"tenant.with.dots",
160+
],
161+
)
162+
def test_access_token_various_tenant_id_formats(tenant_id: str):
163+
"""Test AccessToken accepts various tenant_id formats."""
164+
token = AccessToken(
165+
token="access_token_123",
166+
client_id="test_client",
167+
scopes=["read"],
168+
tenant_id=tenant_id,
169+
)
170+
assert token.tenant_id == tenant_id

0 commit comments

Comments
 (0)