11"""JWT/JWKS authentication against Better Auth SSO.
22
3- Flow:
4- 1. Frontend gets JWT via OAuth2 PKCE from SSO
5- 2. Frontend sends: Authorization: Bearer <JWT>
3+ Supports two token types:
4+ 1. JWT (id_token) - Verified locally using JWKS public keys
5+ 2. Opaque (access_token) - Verified via SSO userinfo endpoint
6+
7+ Flow for JWT:
8+ 1. Frontend/MCP gets JWT via OAuth2 PKCE from SSO
9+ 2. Sends: Authorization: Bearer <JWT>
6103. Backend fetches JWKS public keys from SSO (cached 1 hour)
7114. Backend verifies JWT signature locally (no SSO call per request)
12+
13+ Flow for Opaque Token (e.g., Gemini CLI bug sends access_token):
14+ 1. MCP client gets access_token via OAuth2 from SSO
15+ 2. Sends: Authorization: Bearer <opaque_token>
16+ 3. Backend validates via SSO userinfo endpoint
817"""
918
1019import logging
@@ -132,6 +141,65 @@ async def verify_jwt(token: str) -> dict[str, Any]:
132141 ) from e
133142
134143
144+ async def verify_opaque_token (token : str ) -> dict [str , Any ]:
145+ """Verify opaque access token via SSO userinfo endpoint.
146+
147+ When OAuth clients (like Gemini CLI) send opaque access_tokens instead of JWTs,
148+ we validate them by calling the SSO's userinfo endpoint.
149+
150+ Args:
151+ token: Opaque access token from OAuth flow
152+
153+ Returns:
154+ User claims from userinfo response
155+
156+ Raises:
157+ HTTPException: If token is invalid or expired
158+ """
159+ userinfo_url = f"{ settings .sso_url } /api/auth/oauth2/userinfo"
160+ token_preview = f"{ token [:10 ]} ...{ token [- 10 :]} " if len (token ) > 25 else "[short]"
161+ logger .info ("[AUTH] Validating opaque token via userinfo: %s" , token_preview )
162+
163+ try :
164+ async with httpx .AsyncClient (timeout = 10.0 ) as client :
165+ response = await client .get (
166+ userinfo_url ,
167+ headers = {"Authorization" : f"Bearer { token } " },
168+ )
169+
170+ if response .status_code == 401 :
171+ logger .warning ("[AUTH] Userinfo returned 401 - token invalid or expired" )
172+ raise HTTPException (
173+ status_code = status .HTTP_401_UNAUTHORIZED ,
174+ detail = "Token invalid or expired" ,
175+ headers = {"WWW-Authenticate" : "Bearer" },
176+ )
177+
178+ if response .status_code != 200 :
179+ logger .error ("[AUTH] Userinfo returned %d" , response .status_code )
180+ raise HTTPException (
181+ status_code = status .HTTP_401_UNAUTHORIZED ,
182+ detail = f"Userinfo request failed: { response .status_code } " ,
183+ headers = {"WWW-Authenticate" : "Bearer" },
184+ )
185+
186+ data = response .json ()
187+ logger .info (
188+ "[AUTH] Opaque token verified - sub: %s, email: %s, client: %s" ,
189+ data .get ("sub" ),
190+ data .get ("email" ),
191+ data .get ("client_name" ),
192+ )
193+ return data
194+
195+ except httpx .RequestError as e :
196+ logger .error ("[AUTH] Userinfo request failed: %s" , e )
197+ raise HTTPException (
198+ status_code = status .HTTP_503_SERVICE_UNAVAILABLE ,
199+ detail = f"Authentication service unavailable: { e } " ,
200+ ) from e
201+
202+
135203class CurrentUser :
136204 """Authenticated user extracted from JWT claims.
137205
@@ -142,6 +210,8 @@ class CurrentUser:
142210 - role: "user" | "admin"
143211 - tenant_id: Primary organization (optional)
144212 - organization_id: Alternative tenant claim (optional)
213+ - client_id: OAuth client ID (for audit: which tool was used)
214+ - client_name: OAuth client name (e.g., "Claude Code")
145215 """
146216
147217 def __init__ (self , payload : dict [str , Any ]) -> None :
@@ -153,15 +223,22 @@ def __init__(self, payload: dict[str, Any]) -> None:
153223 self .tenant_id : str | None = (
154224 payload .get ("tenant_id" ) or payload .get ("organization_id" ) or None
155225 )
226+ # OAuth client identity for audit trail (e.g., "@user via Claude Code")
227+ self .client_id : str | None = payload .get ("client_id" )
228+ self .client_name : str | None = payload .get ("client_name" )
156229
157230 def __repr__ (self ) -> str :
158- return f"CurrentUser(id={ self .id !r} , email={ self .email !r} )"
231+ client_info = f", client={ self .client_name !r} " if self .client_name else ""
232+ return f"CurrentUser(id={ self .id !r} , email={ self .email !r} { client_info } )"
159233
160234
161235async def get_current_user (
162236 credentials : HTTPAuthorizationCredentials = Depends (security ),
163237) -> CurrentUser :
164- """FastAPI dependency to get authenticated user from JWT.
238+ """FastAPI dependency to get authenticated user from token.
239+
240+ Supports both JWT (id_token) and opaque (access_token) tokens.
241+ Tries JWT first, falls back to opaque token validation via userinfo.
165242
166243 Usage in routes:
167244 @router.get("/api/projects")
@@ -170,7 +247,7 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
170247 """
171248 # Dev mode bypass for local development
172249 if settings .dev_mode :
173- logger .debug ("[AUTH] Dev mode enabled, bypassing JWT verification" )
250+ logger .debug ("[AUTH] Dev mode enabled, bypassing token verification" )
174251 return CurrentUser (
175252 {
176253 "sub" : settings .dev_user_id ,
@@ -180,12 +257,31 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
180257 }
181258 )
182259
183- logger .debug ("[AUTH] Production mode, verifying JWT..." )
184-
185- # Production: Verify JWT using JWKS
186- payload = await verify_jwt (credentials .credentials )
260+ token = credentials .credentials
261+ token_parts = token .count ("." )
262+
263+ # Detect token type: JWT has 3 dot-separated segments
264+ logger .debug (
265+ "[AUTH] Token validation - segments: %d, type: %s" ,
266+ token_parts + 1 ,
267+ "JWT" if token_parts == 2 else "opaque" ,
268+ )
269+
270+ # Try JWT first if it looks like a JWT
271+ if token_parts == 2 :
272+ try :
273+ payload = await verify_jwt (token )
274+ user = CurrentUser (payload )
275+ logger .info ("[AUTH] Authenticated via JWT: %s" , user )
276+ return user
277+ except HTTPException :
278+ # JWT validation failed, try opaque as fallback
279+ logger .debug ("[AUTH] JWT validation failed, trying opaque token..." )
280+
281+ # Opaque token validation via userinfo endpoint
282+ payload = await verify_opaque_token (token )
187283 user = CurrentUser (payload )
188- logger .info ("[AUTH] Authenticated user : %s" , user )
284+ logger .info ("[AUTH] Authenticated via opaque token : %s" , user )
189285 return user
190286
191287
0 commit comments