11import base64
22import secrets
33import time
4+ from urllib .parse import urlencode , urlparse
45from typing import Any
56
67import jwt
1415from signnow_client .config import load_signnow_config
1516
1617from .config import load_settings
18+ from .token_provider import TokenProvider
1719
1820# ============= CONFIG =============
1921settings = 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+
3847JWKS = {
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+
89124async 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
94128async 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
202225async 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