Skip to content

Commit 90e7646

Browse files
authored
Merge pull request #28 from signnow/refactor/auth-implementation
refactor(auth): URL helpers, token consolidation, middleware fixes, redirect validation
2 parents 6c9feab + ad1b8ae commit 90e7646

File tree

2 files changed

+112
-91
lines changed

2 files changed

+112
-91
lines changed

src/signnow_client/client_other.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,14 @@ def revoke_token(self, token: str) -> bool:
6565
token: Access token to revoke
6666
6767
Returns:
68-
True if successful, False otherwise
68+
True if successful (2xx including 204 No Content), False otherwise. Raises on network/timeout errors.
6969
"""
70-
self._post("/oauth2/terminate", headers={"Accept": "application/json", "Authorization": f"Bearer {token}", "Content-Type": "application/json"}, json_data={})
71-
return True
70+
response = self.http.post(
71+
"/oauth2/terminate",
72+
headers={"Accept": "application/json", "Authorization": f"Bearer {token}", "Content-Type": "application/json"},
73+
json={},
74+
)
75+
return response.is_success
7276

7377
def get_tokens_by_password(self, username: str, password: str, scope: str = None) -> dict[str, Any] | None:
7478
"""

src/sn_mcp_server/auth.py

Lines changed: 105 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import secrets
33
import time
4+
from urllib.parse import urlencode, urlparse
45
from typing import Any
56

67
import jwt
@@ -14,6 +15,7 @@
1415
from signnow_client.config import load_signnow_config
1516

1617
from .config import load_settings
18+
from .token_provider import TokenProvider
1719

1820
# ============= CONFIG =============
1921
settings = load_settings()
@@ -35,6 +37,13 @@ def b64url(b: bytes) -> str:
3537
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
3638

3739

40+
def _url(base: str | Any, *parts: str) -> str:
41+
"""Join base URL with path parts, normalizing slashes."""
42+
base = str(base).rstrip("/")
43+
path = "/".join(p.strip("/") for p in parts if p)
44+
return f"{base}/{path}" if path else base
45+
46+
3847
JWKS = {
3948
"keys": [
4049
{
@@ -64,53 +73,99 @@ def _verify_jwt(token: str) -> dict[str, Any] | None:
6473
issuer=str(settings.oauth_issuer),
6574
options={"require": ["exp", "iat", "iss", "aud"]},
6675
)
67-
except Exception:
76+
except jwt.PyJWTError:
6877
return None
6978

7079

71-
# ============= OAuth endpoints =============
72-
async def openid_config(_: Request) -> JSONResponse:
80+
def _token_response(signnow_response: dict[str, Any]) -> JSONResponse:
81+
"""Build OAuth token response from SignNow API response."""
7382
return JSONResponse(
7483
{
75-
"issuer": str(settings.oauth_issuer),
76-
"authorization_endpoint": f"{str(settings.oauth_issuer)}authorize",
77-
"token_endpoint": f"{str(settings.oauth_issuer)}oauth2/token",
78-
"jwks_uri": f"{str(settings.oauth_issuer)}.well-known/jwks.json",
79-
"registration_endpoint": f"{str(settings.oauth_issuer)}oauth2/register",
80-
"scopes_supported": ["offline_access", "*"],
81-
"response_types_supported": ["code"],
82-
"grant_types_supported": ["authorization_code", "refresh_token"],
83-
"code_challenge_methods_supported": ["S256"],
84-
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
84+
"token_type": signnow_response.get("token_type", "Bearer"),
85+
"access_token": signnow_response.get("access_token"),
86+
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
87+
"refresh_token": signnow_response.get("refresh_token"),
88+
"scope": signnow_response.get("scope", "offline_access *"),
8589
}
8690
)
8791

8892

93+
def _require_string(value: Any, param: str) -> tuple[str | None, JSONResponse | None]:
94+
"""Validate form param is non-empty string. Returns (value, error_response)."""
95+
if not value:
96+
return None, JSONResponse({"error": "invalid_request", "error_description": f"{param} required"}, status_code=400)
97+
if not isinstance(value, str):
98+
return None, JSONResponse({"error": "invalid_request", "error_description": f"{param} must be a string"}, status_code=400)
99+
return value, None
100+
101+
102+
# ============= OAuth endpoints =============
103+
def _openid_configuration() -> dict[str, Any]:
104+
"""Build OAuth/OIDC discovery document with correct URLs."""
105+
base = str(settings.oauth_issuer).rstrip("/")
106+
return {
107+
"issuer": str(settings.oauth_issuer),
108+
"authorization_endpoint": _url(base, "authorize"),
109+
"token_endpoint": _url(base, "oauth2/token"),
110+
"jwks_uri": _url(base, ".well-known/jwks.json"),
111+
"registration_endpoint": _url(base, "oauth2/register"),
112+
"scopes_supported": ["offline_access", "*"],
113+
"response_types_supported": ["code"],
114+
"grant_types_supported": ["authorization_code", "refresh_token"],
115+
"code_challenge_methods_supported": ["S256"],
116+
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
117+
}
118+
119+
120+
async def openid_config(_: Request) -> JSONResponse:
121+
return JSONResponse(_openid_configuration())
122+
123+
89124
async def oauth_as_meta(_: Request) -> JSONResponse:
90-
# can return the same object as openid-configuration
91-
return await openid_config(_)
125+
return JSONResponse(_openid_configuration())
92126

93127

94128
async def jwks(_: Request) -> JSONResponse:
95129
return JSONResponse(JWKS)
96130

97131

98-
async def authorize(req: Request) -> RedirectResponse:
132+
async def authorize(req: Request) -> RedirectResponse | JSONResponse:
99133
q = req.query_params
100134
redirect_uri = q.get("redirect_uri")
101135
state = q.get("state", "")
102136

103-
# Build redirect URL with proper query parameters
104-
base_url = str(signnow_config.app_base) + "authorize"
105-
params = {"response_type": "code", "client_id": signnow_config.client_id, "redirect_uri": redirect_uri}
106-
107-
# Add state only if it exists
137+
if not redirect_uri:
138+
return JSONResponse({"error": "invalid_request", "error_description": "redirect_uri required"}, status_code=400)
139+
140+
# Validate redirect_uri against allowed list (exact match or same scheme+host; any port when allowed has no port)
141+
allowed = settings.allowed_redirects_list
142+
if allowed:
143+
parsed = urlparse(redirect_uri)
144+
redirect_scheme = parsed.scheme
145+
redirect_host = (parsed.hostname or "").lower()
146+
redirect_port = parsed.port
147+
148+
def _matches(allowed_uri: str) -> bool:
149+
if redirect_uri == allowed_uri:
150+
return True
151+
a = urlparse(allowed_uri)
152+
if redirect_scheme != a.scheme:
153+
return False
154+
if (a.hostname or "").lower() != redirect_host:
155+
return False
156+
# Same scheme and host: allow if exact port match, or if allowed has no port (any port ok)
157+
if a.port is None:
158+
return True
159+
return redirect_port == a.port
160+
161+
if not any(_matches(a) for a in allowed):
162+
return JSONResponse({"error": "invalid_request", "error_description": "redirect_uri not allowed"}, status_code=400)
163+
164+
base_url = _url(signnow_config.app_base, "authorize")
165+
params: dict[str, str] = {"response_type": "code", "client_id": signnow_config.client_id, "redirect_uri": redirect_uri}
108166
if state:
109167
params["state"] = state
110-
111-
# Build query string
112-
query_string = "&".join(f"{key}={value}" for key, value in params.items())
113-
redirect_url = f"{base_url}?{query_string}"
168+
redirect_url = f"{base_url}?{urlencode(params)}"
114169

115170
return RedirectResponse(redirect_url, status_code=302)
116171

@@ -120,56 +175,24 @@ async def token(req: Request) -> JSONResponse:
120175
grant_type = form.get("grant_type")
121176

122177
if grant_type == "authorization_code":
123-
code = form.get("code")
124-
125-
# Get tokens from SignNow API
126-
if not code:
127-
return JSONResponse({"error": "invalid_request", "error_description": "code parameter required"}, status_code=400)
128-
if isinstance(code, str):
129-
signnow_response = signnow_client.get_tokens(code=code)
130-
else:
131-
return JSONResponse({"error": "invalid_request", "error_description": "code must be a string"}, status_code=400)
132-
178+
code, err = _require_string(form.get("code"), "code")
179+
if err:
180+
return err
181+
assert code is not None
182+
signnow_response = signnow_client.get_tokens(code=code)
133183
if not signnow_response:
134184
return JSONResponse({"error": "external_token_error"}, status_code=500)
135-
136-
# Return tokens from SignNow API
137-
return JSONResponse(
138-
{
139-
"token_type": signnow_response.get("token_type", "Bearer"),
140-
"access_token": signnow_response.get("access_token"),
141-
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
142-
"refresh_token": signnow_response.get("refresh_token"),
143-
"scope": "offline_access *",
144-
}
145-
)
185+
return _token_response(signnow_response)
146186

147187
elif grant_type == "refresh_token":
148-
refresh = form.get("refresh_token")
149-
150-
if not refresh:
151-
return JSONResponse({"error": "invalid_grant"}, status_code=400)
152-
153-
# Get new tokens from SignNow API using refresh token
154-
if not refresh:
155-
return JSONResponse({"error": "invalid_request", "error_description": "refresh_token parameter required"}, status_code=400)
156-
if isinstance(refresh, str):
157-
signnow_response = signnow_client.refresh_tokens(refresh_token=refresh)
158-
else:
159-
return JSONResponse({"error": "invalid_request", "error_description": "refresh_token must be a string"}, status_code=400)
160-
161-
if signnow_response:
162-
return JSONResponse(
163-
{
164-
"token_type": signnow_response.get("token_type", "Bearer"),
165-
"access_token": signnow_response.get("access_token"),
166-
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
167-
"refresh_token": signnow_response.get("refresh_token"),
168-
"scope": signnow_response.get("scope", "*"),
169-
}
170-
)
171-
else:
188+
refresh, err = _require_string(form.get("refresh_token"), "refresh_token")
189+
if err:
190+
return err
191+
assert refresh is not None
192+
signnow_response = signnow_client.refresh_tokens(refresh_token=refresh)
193+
if not signnow_response:
172194
return JSONResponse({"error": "invalid_grant"}, status_code=400)
195+
return _token_response(signnow_response)
173196

174197
else:
175198
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
@@ -201,21 +224,16 @@ async def introspect(req: Request) -> JSONResponse:
201224

202225
async def revoke(req: Request) -> PlainTextResponse | JSONResponse:
203226
form = await req.form()
204-
token = form.get("token")
205-
206-
if not token:
207-
return JSONResponse({"error": "invalid_request", "error_description": "token parameter required"}, status_code=400)
208-
209-
# Send revoke request to SignNow API
210-
if not token:
211-
return JSONResponse({"error": "invalid_request", "error_description": "token parameter required"}, status_code=400)
212-
if isinstance(token, str):
227+
token, err = _require_string(form.get("token"), "token")
228+
if err:
229+
return err
230+
assert token is not None
231+
try:
213232
if signnow_client.revoke_token(token):
214233
return PlainTextResponse("", status_code=200)
215-
else:
216-
return JSONResponse({"error": "external_revoke_error"}, status_code=500)
217-
else:
218-
return JSONResponse({"error": "invalid_request", "error_description": "token must be a string"}, status_code=400)
234+
return JSONResponse({"error": "external_revoke_error"}, status_code=500)
235+
except Exception:
236+
return JSONResponse({"error": "external_revoke_error"}, status_code=500)
219237

220238

221239
# ============= PRM (Protected Resource Metadata) =============
@@ -262,7 +280,7 @@ async def register(req: Request) -> JSONResponse:
262280
"token_endpoint_auth_method": token_method,
263281
"grant_types": ["authorization_code", "refresh_token"],
264282
"response_types": ["code"],
265-
"registration_client_uri": f"{str(settings.oauth_issuer)}/oauth2/register/{client_id}",
283+
"registration_client_uri": _url(settings.oauth_issuer, "oauth2/register", client_id),
266284
"client_secret_expires_at": 0,
267285
}
268286
if client_secret:
@@ -298,20 +316,19 @@ class BearerJWTASGIMiddleware:
298316
def __init__(self, app: Any, protect_prefixes: tuple[str, ...] = ("/mcp", "/sse", "/messages")) -> None:
299317
self.app = app
300318
self._paths = tuple(protect_prefixes)
301-
from .token_provider import TokenProvider
302-
303319
self.token_provider = TokenProvider()
304320

305321
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
306322
if scope["type"] != "http":
307323
await self.app(scope, receive, send)
324+
return
308325

309326
request = Request(scope, receive=receive)
310327
path = request.url.path
311328

312-
# Let CORS outside handle preflight
313329
if request.method == "OPTIONS":
314330
await self.app(scope, receive, send)
331+
return
315332

316333
if any(path.startswith(x) for x in self._paths):
317334
if not self.token_provider.has_config_credentials():
@@ -322,7 +339,7 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None
322339
"type": "http.response.start",
323340
"status": 401,
324341
"headers": [
325-
(b"www-authenticate", f'Bearer resource_metadata="{str(settings.oauth_issuer)}/.well-known/oauth-protected-resource"'.encode()),
342+
(b"www-authenticate", f'Bearer resource_metadata="{_url(settings.oauth_issuer, ".well-known/oauth-protected-resource")}"'.encode()),
326343
(b"content-type", b"text/plain; charset=utf-8"),
327344
],
328345
}

0 commit comments

Comments
 (0)