Skip to content

Commit 167b815

Browse files
authored
Fixes to the OIDC endpoint (#702)
* OIDC fixes * Rstrip extra slash
1 parent 7104dde commit 167b815

File tree

2 files changed

+139
-82
lines changed

2 files changed

+139
-82
lines changed

gramps_webapi/api/resources/oidc.py

Lines changed: 103 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,29 @@
1919

2020
"""OIDC authentication resources."""
2121

22-
import json
2322
import logging
23+
from gettext import gettext as _
2424
from typing import Optional
2525
from urllib.parse import urlencode
2626

2727
import 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
3137
from 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+
)
3545
from ...auth.oidc_helpers import is_oidc_enabled
3646
from ...auth.token_blocklist import add_jti_to_blocklist
3747
from ...const import TREE_MULTI
@@ -45,7 +55,9 @@
4555

4656
def _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

5163
class 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

Comments
 (0)