1919
2020"""OIDC authentication resources."""
2121
22- import json
2322import logging
23+ from gettext import gettext as _
2424from typing import Optional
2525from urllib .parse import urlencode
2626
2727import jwt
28- from flask import current_app , request , session , url_for , redirect , jsonify , render_template
29- from flask_jwt_extended import decode_token
30- from gettext import gettext as _
28+ from flask import (
29+ current_app ,
30+ jsonify ,
31+ redirect ,
32+ render_template ,
33+ request ,
34+ url_for ,
35+ )
36+ from marshmallow import EXCLUDE
3137from webargs import fields
3238
33- from ...auth import get_name , get_permissions , is_tree_disabled , get_user_details
34- from ...auth .oidc import create_or_update_oidc_user , get_available_oidc_providers , get_provider_config
39+ from ...auth import get_name , get_permissions , get_user_details , is_tree_disabled
40+ from ...auth .oidc import (
41+ create_or_update_oidc_user ,
42+ get_available_oidc_providers ,
43+ get_provider_config ,
44+ )
3545from ...auth .oidc_helpers import is_oidc_enabled
3646from ...auth .token_blocklist import add_jti_to_blocklist
3747from ...const import TREE_MULTI
4555
4656def _is_development_environment (frontend_url : Optional [str ]) -> bool :
4757 """Check if we're in a development environment for cookie security settings."""
48- return current_app .debug or ('localhost' in (frontend_url or '' ) or '127.0.0.1' in (frontend_url or '' ))
58+ return current_app .debug or (
59+ "localhost" in (frontend_url or "" ) or "127.0.0.1" in (frontend_url or "" )
60+ )
4961
5062
5163class OIDCLoginResource (Resource ):
@@ -79,10 +91,14 @@ def get(self, args):
7991
8092 oidc_client = getattr (oauth , f"gramps_{ provider_id } " , None )
8193 if not oidc_client :
82- abort_with_message (500 , f"OIDC client for provider '{ provider_id } ' not found" )
94+ abort_with_message (
95+ 500 , f"OIDC client for provider '{ provider_id } ' not found"
96+ )
8397
8498 # Build redirect URI with provider parameter
85- redirect_uri = url_for ("api.oidccallbackresource" , provider = provider_id , _external = True )
99+ redirect_uri = url_for (
100+ "api.oidccallbackresource" , provider = provider_id , _external = True
101+ )
86102
87103 authorization_url = oidc_client .authorize_redirect (redirect_uri )
88104 return authorization_url
@@ -106,6 +122,7 @@ class OIDCCallbackResource(Resource):
106122 "error_description" : fields .Str (required = False ),
107123 },
108124 location = "query" ,
125+ unknown = EXCLUDE ,
109126 )
110127 def get (self , args ):
111128 """Handle OIDC callback and create JWT tokens."""
@@ -125,7 +142,9 @@ def get(self, args):
125142
126143 oidc_client = getattr (oauth , f"gramps_{ provider_id } " , None )
127144 if not oidc_client :
128- abort_with_message (500 , f"OIDC client for provider '{ provider_id } ' not found" )
145+ abort_with_message (
146+ 500 , f"OIDC client for provider '{ provider_id } ' not found"
147+ )
129148
130149 try :
131150 token = oidc_client .authorize_access_token ()
@@ -150,10 +169,7 @@ def get(self, args):
150169 and tree != current_app .config ["TREE" ]
151170 ):
152171 abort_with_message (403 , f"Invalid tree: { tree } " )
153- if (
154- not tree
155- and current_app .config ["TREE" ] == TREE_MULTI
156- ):
172+ if not tree and current_app .config ["TREE" ] == TREE_MULTI :
157173 abort_with_message (403 , "Tree is required" )
158174
159175 try :
@@ -173,7 +189,9 @@ def get(self, args):
173189 "Your account has been created successfully. "
174190 "An administrator will review your account request and activate it shortly."
175191 )
176- return render_template ("confirmation.html" , title = title , message = message )
192+ return render_template (
193+ "confirmation.html" , title = title , message = message
194+ )
177195
178196 # User is enabled - proceed with normal token flow
179197 permissions = get_permissions (username = username , tree = tree_id )
@@ -189,47 +207,51 @@ def get(self, args):
189207
190208 # Redirect to frontend with secure HTTP-only cookies
191209 frontend_url = get_config ("FRONTEND_URL" ) or get_config ("BASE_URL" )
192- response = redirect (f"{ frontend_url } /oidc/complete" )
210+ response = redirect (f"{ frontend_url . rstrip ( '/' ) } /oidc/complete" )
193211
194212 # Set HTTP-only cookies (secure=False for localhost development)
195213 is_development = _is_development_environment (frontend_url )
196214
197215 response .set_cookie (
198- ' oidc_access_token' ,
199- tokens [' access_token' ],
216+ " oidc_access_token" ,
217+ tokens [" access_token" ],
200218 max_age = 300 , # 5 minutes
201219 httponly = True ,
202220 secure = not is_development , # Allow HTTP in development
203- samesite = ' Lax' ,
204- path = '/'
221+ samesite = " Lax" ,
222+ path = "/" ,
205223 )
206224 response .set_cookie (
207- ' oidc_refresh_token' ,
208- tokens [' refresh_token' ],
225+ " oidc_refresh_token" ,
226+ tokens [" refresh_token" ],
209227 max_age = 300 , # 5 minutes
210228 httponly = True ,
211229 secure = not is_development , # Allow HTTP in development
212- samesite = ' Lax' ,
213- path = '/'
230+ samesite = " Lax" ,
231+ path = "/" ,
214232 )
215233
216234 # Store id_token if available (needed for OIDC logout)
217- if token .get (' id_token' ):
235+ if token .get (" id_token" ):
218236 response .set_cookie (
219- ' oidc_id_token' ,
220- token [' id_token' ],
237+ " oidc_id_token" ,
238+ token [" id_token" ],
221239 max_age = 300 , # 5 minutes
222240 httponly = True ,
223241 secure = not is_development , # Allow HTTP in development
224- samesite = ' Lax' ,
225- path = '/'
242+ samesite = " Lax" ,
243+ path = "/" ,
226244 )
227245
228- logger .info (f"Set OIDC cookies, redirecting to { frontend_url } /oidc/complete" )
246+ logger .info (
247+ f"Set OIDC cookies, redirecting to { frontend_url } /oidc/complete"
248+ )
229249 return response
230250
231251 except ValueError as e :
232- logger .exception (f"Error creating/updating OIDC user for provider '{ provider_id } '" )
252+ logger .exception (
253+ f"Error creating/updating OIDC user for provider '{ provider_id } '"
254+ )
233255 abort_with_message (400 , f"Error processing user: { str (e )} " )
234256
235257
@@ -243,9 +265,9 @@ def get(self):
243265 logger .info (f"Cookies received: { list (request .cookies .keys ())} " )
244266
245267 # Get tokens from HTTP-only cookies
246- access_token = request .cookies .get (' oidc_access_token' )
247- refresh_token = request .cookies .get (' oidc_refresh_token' )
248- id_token = request .cookies .get (' oidc_id_token' )
268+ access_token = request .cookies .get (" oidc_access_token" )
269+ refresh_token = request .cookies .get (" oidc_refresh_token" )
270+ id_token = request .cookies .get (" oidc_id_token" )
249271
250272 logger .info (f"Access token found: { bool (access_token )} " )
251273 logger .info (f"Refresh token found: { bool (refresh_token )} " )
@@ -257,14 +279,14 @@ def get(self):
257279
258280 # Return tokens and clear cookies
259281 response_data = {
260- ' access_token' : access_token ,
261- ' refresh_token' : refresh_token ,
262- ' token_type' : ' Bearer'
282+ " access_token" : access_token ,
283+ " refresh_token" : refresh_token ,
284+ " token_type" : " Bearer" ,
263285 }
264286
265287 # Include id_token if available (needed for OIDC logout)
266288 if id_token :
267- response_data [' id_token' ] = id_token
289+ response_data [" id_token" ] = id_token
268290
269291 response = jsonify (response_data )
270292
@@ -273,31 +295,31 @@ def get(self):
273295 is_development = _is_development_environment (frontend_url )
274296
275297 response .set_cookie (
276- ' oidc_access_token' ,
277- '' ,
298+ " oidc_access_token" ,
299+ "" ,
278300 expires = 0 ,
279301 httponly = True ,
280302 secure = not is_development ,
281- samesite = ' Lax' ,
282- path = '/'
303+ samesite = " Lax" ,
304+ path = "/" ,
283305 )
284306 response .set_cookie (
285- ' oidc_refresh_token' ,
286- '' ,
307+ " oidc_refresh_token" ,
308+ "" ,
287309 expires = 0 ,
288310 httponly = True ,
289311 secure = not is_development ,
290- samesite = ' Lax' ,
291- path = '/'
312+ samesite = " Lax" ,
313+ path = "/" ,
292314 )
293315 response .set_cookie (
294- ' oidc_id_token' ,
295- '' ,
316+ " oidc_id_token" ,
317+ "" ,
296318 expires = 0 ,
297319 httponly = True ,
298320 secure = not is_development ,
299- samesite = ' Lax' ,
300- path = '/'
321+ samesite = " Lax" ,
322+ path = "/" ,
301323 )
302324
303325 logger .info ("OIDC token exchange successful, cookies cleared" )
@@ -321,16 +343,24 @@ def get(self):
321343 for provider_id in available_providers :
322344 provider_config = get_provider_config (provider_id )
323345 if provider_config :
324- providers .append ({
325- "id" : provider_id ,
326- "name" : provider_config ["name" ],
327- "login_url" : url_for ("api.oidcloginresource" , provider = provider_id , _external = True ),
328- })
346+ providers .append (
347+ {
348+ "id" : provider_id ,
349+ "name" : provider_config ["name" ],
350+ "login_url" : url_for (
351+ "api.oidcloginresource" ,
352+ provider = provider_id ,
353+ _external = True ,
354+ ),
355+ }
356+ )
329357
330358 return {
331359 "enabled" : True ,
332360 "providers" : providers ,
333- "disable_local_auth" : current_app .config .get ("OIDC_DISABLE_LOCAL_AUTH" , False ),
361+ "disable_local_auth" : current_app .config .get (
362+ "OIDC_DISABLE_LOCAL_AUTH" , False
363+ ),
334364 "auto_redirect" : current_app .config .get ("OIDC_AUTO_REDIRECT" , True ),
335365 }
336366
@@ -368,12 +398,16 @@ def get(self, args):
368398
369399 oidc_client = getattr (oauth , f"gramps_{ provider_id } " , None )
370400 if not oidc_client :
371- abort_with_message (500 , f"OIDC client for provider '{ provider_id } ' not found" )
401+ abort_with_message (
402+ 500 , f"OIDC client for provider '{ provider_id } ' not found"
403+ )
372404
373405 try :
374406 # Load server metadata to get end_session_endpoint
375407 oidc_client .load_server_metadata ()
376- end_session_endpoint = oidc_client .server_metadata .get ("end_session_endpoint" )
408+ end_session_endpoint = oidc_client .server_metadata .get (
409+ "end_session_endpoint"
410+ )
377411
378412 if not end_session_endpoint :
379413 # Provider doesn't support OIDC logout - graceful degradation
@@ -384,7 +418,9 @@ def get(self, args):
384418 if args .get ("id_token" ):
385419 params ["id_token_hint" ] = args .get ("id_token" )
386420 if args .get ("post_logout_redirect_uri" ):
387- params ["post_logout_redirect_uri" ] = args .get ("post_logout_redirect_uri" )
421+ params ["post_logout_redirect_uri" ] = args .get (
422+ "post_logout_redirect_uri"
423+ )
388424
389425 logout_url = end_session_endpoint
390426 if params :
@@ -423,13 +459,17 @@ def post(self):
423459
424460 try :
425461 # Decode the logout token without verification first to get the issuer
426- unverified_claims = jwt .decode (logout_token , options = {"verify_signature" : False })
462+ unverified_claims = jwt .decode (
463+ logout_token , options = {"verify_signature" : False }
464+ )
427465 except jwt .InvalidTokenError as e :
428466 logger .exception ("Invalid logout_token in backchannel logout request" )
429467 abort_with_message (400 , f"Invalid logout_token: { str (e )} " )
430468
431- logger .info (f"Received backchannel logout for sub={ unverified_claims .get ('sub' )} , "
432- f"sid={ unverified_claims .get ('sid' )} " )
469+ logger .info (
470+ f"Received backchannel logout for sub={ unverified_claims .get ('sub' )} , "
471+ f"sid={ unverified_claims .get ('sid' )} "
472+ )
433473
434474 # Validate the logout token structure per OIDC Back-Channel Logout spec
435475 if "sub" not in unverified_claims and "sid" not in unverified_claims :
@@ -465,4 +505,4 @@ def post(self):
465505 )
466506
467507 # Return success per spec (200 OK)
468- return "" , 200
508+ return "" , 200
0 commit comments