3939
4040from iris .cluster .controller .service import ControllerServiceImpl
4141from iris .cluster .dashboard_common import html_shell , static_files_mount
42- from iris .rpc .auth import SESSION_COOKIE , AuthInterceptor , NullAuthInterceptor , TokenVerifier
42+ from iris .rpc .auth import SESSION_COOKIE , NullAuthInterceptor , TokenVerifier , extract_bearer_token , resolve_auth
4343from iris .rpc .cluster_connect import ControllerServiceWSGIApplication
4444from iris .rpc .interceptors import RequestTimingInterceptor
4545
@@ -86,11 +86,15 @@ class _RouteAuthMiddleware:
8686 @public / @requires_auth annotation. Routes without an annotation are
8787 denied (default-deny). RPC Mount routes and static file mounts are
8888 skipped (they have their own auth).
89+
90+ Uses resolve_auth() — the same policy function as the gRPC interceptor —
91+ so HTTP and gRPC layers agree on allow/deny for every token state.
8992 """
9093
91- def __init__ (self , app : Starlette , verifier : TokenVerifier ):
94+ def __init__ (self , app : Starlette , verifier : TokenVerifier , optional : bool = False ):
9295 self ._app = app
9396 self ._verifier = verifier
97+ self ._optional = optional
9498 self ._router = app .router
9599
96100 async def __call__ (self , scope : Scope , receive : Receive , send : Send ) -> None :
@@ -134,15 +138,13 @@ def _resolve_policy(self, scope: Scope) -> str:
134138
135139 async def _check_auth (self , scope : Scope , receive : Receive , send : Send ) -> None :
136140 token = _extract_token_from_scope (scope )
137- if token is None :
138- response = JSONResponse ({"error" : "authentication required" }, status_code = 401 )
139- return await response (scope , receive , send )
140141 try :
141- identity = self ._verifier . verify ( token )
142+ identity = resolve_auth ( token , self ._verifier , self . _optional )
142143 except ValueError :
143- response = JSONResponse ({"error" : "invalid session " }, status_code = 401 )
144+ response = JSONResponse ({"error" : "authentication required " }, status_code = 401 )
144145 return await response (scope , receive , send )
145- scope ["auth_identity" ] = identity
146+ if identity is not None :
147+ scope ["auth_identity" ] = identity
146148 return await self ._app (scope , receive , send )
147149
148150
@@ -168,16 +170,49 @@ def _check_csrf(request: Request) -> bool:
168170 return origin == expected_origin
169171
170172
171- class _SelectiveAuthInterceptor :
172- """Auth interceptor that skips authentication for specific RPC methods."""
173+ class _DashboardAuthInterceptor :
174+ """RPC auth interceptor that uses resolve_auth() — same policy as HTTP middleware.
173175
174- def __init__ (self , verifier : TokenVerifier ):
175- self ._inner = AuthInterceptor (verifier )
176+ Login and GetAuthInfo RPCs are always unauthenticated. All other RPCs go
177+ through resolve_auth(token, verifier, optional) which:
178+ - token present + valid → authenticated identity
179+ - token present + invalid → rejected
180+ - no token + optional → anonymous/admin fallback via NullAuthInterceptor
181+ - no token + required → rejected
182+ """
183+
184+ def __init__ (self , verifier : TokenVerifier , optional : bool = False ):
185+ self ._verifier = verifier
186+ self ._optional = optional
187+ self ._null = NullAuthInterceptor (verifier = verifier )
176188
177189 def intercept_unary_sync (self , call_next , request , ctx ):
190+ from iris .rpc .auth import _verified_identity
191+
178192 if ctx .method ().name in _UNAUTHENTICATED_RPCS :
179193 return call_next (request , ctx )
180- return self ._inner .intercept_unary_sync (call_next , request , ctx )
194+
195+ token = extract_bearer_token (ctx .request_headers ())
196+ try :
197+ identity = resolve_auth (token , self ._verifier , self ._optional )
198+ except ValueError as exc :
199+ from connectrpc .code import Code
200+ from connectrpc .errors import ConnectError
201+
202+ if token is None :
203+ raise ConnectError (Code .UNAUTHENTICATED , str (exc )) from exc
204+ logger .warning ("Authentication failed: %s" , exc )
205+ raise ConnectError (Code .UNAUTHENTICATED , "Authentication failed" ) from exc
206+
207+ if identity is None :
208+ # Optional mode, no token — anonymous fallback.
209+ return self ._null .intercept_unary_sync (call_next , request , ctx )
210+
211+ reset_token = _verified_identity .set (identity )
212+ try :
213+ return call_next (request , ctx )
214+ finally :
215+ _verified_identity .reset (reset_token )
181216
182217
183218class ControllerDashboard :
@@ -196,12 +231,14 @@ def __init__(
196231 port : int = 8080 ,
197232 auth_verifier : TokenVerifier | None = None ,
198233 auth_provider : str | None = None ,
234+ auth_optional : bool = False ,
199235 ):
200236 self ._service = service
201237 self ._host = host
202238 self ._port = port
203239 self ._auth_verifier = auth_verifier
204240 self ._auth_provider = auth_provider
241+ self ._auth_optional = auth_optional
205242 self ._app = self ._create_app ()
206243
207244 @property
@@ -214,9 +251,11 @@ def app(self) -> ASGIApp:
214251
215252 def _create_app (self ) -> ASGIApp :
216253 interceptors = [RequestTimingInterceptor (include_traceback = bool (os .environ .get ("IRIS_DEBUG" )))]
217- if self ._auth_provider is not None :
218- interceptors .insert (0 , _SelectiveAuthInterceptor (self ._auth_verifier ))
254+ if self ._auth_provider is not None and self . _auth_verifier is not None :
255+ interceptors .insert (0 , _DashboardAuthInterceptor (self ._auth_verifier , optional = self . _auth_optional ))
219256 else :
257+ # Null-auth mode: no provider configured. Verify worker tokens
258+ # when present but treat everything as anonymous/admin.
220259 interceptors .insert (0 , NullAuthInterceptor (verifier = self ._auth_verifier ))
221260 rpc_wsgi_app = ControllerServiceWSGIApplication (service = self ._service , interceptors = interceptors )
222261 rpc_app = WSGIMiddleware (rpc_wsgi_app )
@@ -236,7 +275,7 @@ def _create_app(self) -> ASGIApp:
236275 ]
237276 app : Starlette | _RouteAuthMiddleware = Starlette (routes = routes )
238277 if self ._auth_verifier is not None and self ._auth_provider is not None :
239- app = _RouteAuthMiddleware (app , self ._auth_verifier )
278+ app = _RouteAuthMiddleware (app , self ._auth_verifier , optional = self . _auth_optional )
240279 return app
241280
242281 @public
@@ -283,6 +322,7 @@ def _auth_config(self, request: Request) -> JSONResponse:
283322 "provider" : self ._auth_provider ,
284323 "has_session" : has_session ,
285324 "provider_kind" : provider_kind ,
325+ "optional" : self ._auth_optional ,
286326 }
287327 )
288328
0 commit comments