5757 partition_seconds = 60 ,
5858 active_partitions = 2 ,
5959)
60- login_rate_limiter = fastapi_ip_rate_limiter (
61- rate_limiter ,
62- paths = [
60+
61+
62+ def create_auth_router (ldap_enabled : bool = False ) -> APIRouter :
63+ """Create auth router with all authentication endpoints.
64+
65+ Creates a fresh router instance each time to avoid global state issues
66+ (e.g., route accumulation in tests).
67+
68+ Security: Only registers the /ldap/login endpoint when LDAP is actually configured.
69+ This prevents information disclosure and reduces attack surface.
70+
71+ Args:
72+ ldap_enabled: Whether LDAP authentication is configured
73+
74+ Returns:
75+ APIRouter: Authentication router with all endpoints registered
76+ """
77+ # Build rate limiter paths based on configuration
78+ rate_limited_paths = [
6379 "/auth/login" ,
64- "/auth/ldap/login" ,
6580 "/auth/logout" ,
6681 "/auth/refresh" ,
6782 "/auth/password-reset-email" ,
6883 "/auth/password-reset" ,
69- ],
70- )
84+ ]
85+ if ldap_enabled :
86+ rate_limited_paths .append ("/auth/ldap/login" )
87+
88+ login_rate_limiter = fastapi_ip_rate_limiter (rate_limiter , paths = rate_limited_paths )
89+ auth_dependencies = [Depends (login_rate_limiter )] if not get_env_disable_rate_limit () else []
90+
91+ router = APIRouter (prefix = "/auth" , include_in_schema = False , dependencies = auth_dependencies )
92+
93+ # Register all authentication endpoints
94+ router .add_api_route ("/login" , _login , methods = ["POST" ])
95+ router .add_api_route ("/logout" , _logout , methods = ["GET" ])
96+ router .add_api_route ("/refresh" , _refresh_tokens , methods = ["POST" ])
97+ router .add_api_route ("/password-reset-email" , _initiate_password_reset , methods = ["POST" ])
98+ router .add_api_route ("/password-reset" , _reset_password , methods = ["POST" ])
99+
100+ # Conditionally add LDAP endpoint only if configured
101+ if ldap_enabled :
102+ router .add_api_route ("/ldap/login" , _ldap_login , methods = ["POST" ])
71103
72- auth_dependencies = [Depends (login_rate_limiter )] if not get_env_disable_rate_limit () else []
73- router = APIRouter (prefix = "/auth" , include_in_schema = False , dependencies = auth_dependencies )
104+ return router
74105
75106
76- @ router . post ( "/login" )
77- async def login ( request : Request ) -> Response :
107+ async def _login ( request : Request ) -> Response :
108+ """Authenticate user via email/password and return access/refresh tokens."""
78109 if get_env_disable_basic_auth ():
79110 raise HTTPException (status_code = 403 )
80111 data = await request .json ()
@@ -110,10 +141,8 @@ async def login(request: Request) -> Response:
110141 return await _create_auth_response (request , user )
111142
112143
113- @router .get ("/logout" )
114- async def logout (
115- request : Request ,
116- ) -> Response :
144+ async def _logout (request : Request ) -> Response :
145+ """Log out user by revoking tokens and clearing cookies."""
117146 token_store : TokenStore = request .app .state .get_token_store ()
118147 user_id = None
119148 if isinstance (user := request .user , PhoenixUser ):
@@ -138,8 +167,8 @@ async def logout(
138167 return response
139168
140169
141- @ router . post ( "/refresh" )
142- async def refresh_tokens ( request : Request ) -> Response :
170+ async def _refresh_tokens ( request : Request ) -> Response :
171+ """Refresh access and refresh tokens."""
143172 if (refresh_token := request .cookies .get (PHOENIX_REFRESH_TOKEN_COOKIE_NAME )) is None :
144173 raise HTTPException (status_code = 401 , detail = "Missing refresh token" )
145174 token_store : TokenStore = request .app .state .get_token_store ()
@@ -176,8 +205,8 @@ async def refresh_tokens(request: Request) -> Response:
176205 return await _create_auth_response (request , user )
177206
178207
179- @ router . post ( "/password-reset-email" )
180- async def initiate_password_reset ( request : Request ) -> Response :
208+ async def _initiate_password_reset ( request : Request ) -> Response :
209+ """Send password reset email to user."""
181210 if get_env_disable_basic_auth ():
182211 raise HTTPException (status_code = 403 )
183212 data = await request .json ()
@@ -220,8 +249,8 @@ async def initiate_password_reset(request: Request) -> Response:
220249 return Response (status_code = 204 )
221250
222251
223- @ router . post ( "/password-reset" )
224- async def reset_password ( request : Request ) -> Response :
252+ async def _reset_password ( request : Request ) -> Response :
253+ """Reset user password using a valid reset token."""
225254 if get_env_disable_basic_auth ():
226255 raise HTTPException (status_code = 403 )
227256 data = await request .json ()
@@ -258,6 +287,37 @@ async def reset_password(request: Request) -> Response:
258287 return response
259288
260289
290+ async def _ldap_login (request : Request ) -> Response :
291+ """Authenticate user via LDAP and return access/refresh tokens."""
292+ # Use cached authenticator instance to avoid re-parsing TLS config on every request
293+ authenticator : LDAPAuthenticator | None = getattr (request .app .state , "ldap_authenticator" , None )
294+
295+ if not authenticator :
296+ raise HTTPException (
297+ status_code = 503 , detail = "LDAP authentication is not configured on this server"
298+ )
299+
300+ data = await request .json ()
301+ username = data .get ("username" )
302+ password = data .get ("password" )
303+
304+ if not username or not password :
305+ raise HTTPException (status_code = 401 , detail = "Username and password required" )
306+
307+ # Authenticate against LDAP (reused authenticator, already parsed TLS config)
308+ user_info = await authenticator .authenticate (username , password )
309+
310+ if not user_info :
311+ # Generic error message to prevent username enumeration
312+ raise HTTPException (status_code = 401 , detail = "Invalid username and/or password" )
313+
314+ # Get or create user in Phoenix database
315+ async with request .app .state .db () as session :
316+ user = await get_or_create_ldap_user (session , user_info , authenticator .config )
317+
318+ return await _create_auth_response (request , user )
319+
320+
261321async def _create_auth_response (request : Request , user : models .User ) -> Response :
262322 """
263323 Creates access and refresh tokens for the user and sets them as cookies in the response.
@@ -300,54 +360,3 @@ async def _create_auth_response(request: Request, user: models.User) -> Response
300360 status_code = 401 ,
301361 detail = "Invalid token" ,
302362)
303-
304-
305- def create_auth_router (ldap_enabled : bool = False ) -> APIRouter :
306- """Create auth router with conditional LDAP endpoint registration.
307-
308- Security: Only registers the /ldap/login endpoint when LDAP is actually configured.
309- This prevents information disclosure and reduces attack surface.
310-
311- Args:
312- ldap_enabled: Whether LDAP authentication is configured
313-
314- Returns:
315- APIRouter: Authentication router (with or without LDAP endpoint)
316- """
317- # router already has all non-LDAP endpoints registered via decorators
318- # Conditionally add LDAP endpoint only if configured
319- if ldap_enabled :
320- router .add_api_route ("/ldap/login" , ldap_login , methods = ["POST" ])
321-
322- return router
323-
324-
325- async def ldap_login (request : Request ) -> Response :
326- """Authenticate user via LDAP and return access/refresh tokens."""
327- # Use cached authenticator instance to avoid re-parsing TLS config on every request
328- authenticator : LDAPAuthenticator | None = getattr (request .app .state , "ldap_authenticator" , None )
329-
330- if not authenticator :
331- raise HTTPException (
332- status_code = 503 , detail = "LDAP authentication is not configured on this server"
333- )
334-
335- data = await request .json ()
336- username = data .get ("username" )
337- password = data .get ("password" )
338-
339- if not username or not password :
340- raise HTTPException (status_code = 401 , detail = "Username and password required" )
341-
342- # Authenticate against LDAP (reused authenticator, already parsed TLS config)
343- user_info = await authenticator .authenticate (username , password )
344-
345- if not user_info :
346- # Generic error message to prevent username enumeration
347- raise HTTPException (status_code = 401 , detail = "Invalid username and/or password" )
348-
349- # Get or create user in Phoenix database
350- async with request .app .state .db () as session :
351- user = await get_or_create_ldap_user (session , user_info , authenticator .config )
352-
353- return await _create_auth_response (request , user )
0 commit comments