Skip to content

Commit 420216e

Browse files
authored
Allow OIDC callback URL without query parameters (#729)
* Allow OIDC callback URL without query parameters * Update apispec
1 parent 1098d10 commit 420216e

File tree

4 files changed

+204
-10
lines changed

4 files changed

+204
-10
lines changed

gramps_webapi/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
168168
# OIDC
169169
register_endpt(OIDCLoginResource, "/oidc/login/", "oidcloginresource")
170170
register_endpt(OIDCCallbackResource, "/oidc/callback/", "oidccallbackresource")
171+
register_endpt(
172+
OIDCCallbackResource,
173+
"/oidc/callback/<string:provider_id>",
174+
"oidccallbackresource_provider",
175+
)
171176
register_endpt(OIDCConfigResource, "/oidc/config/", "oidcconfigresource")
172177
register_endpt(OIDCTokenExchangeResource, "/oidc/tokens/", "oidctokenexchangeresource")
173178
register_endpt(OIDCLogoutResource, "/oidc/logout/", "oidclogoutresource")

gramps_webapi/api/resources/oidc.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,10 @@ def get(self, args):
9494
500, f"OIDC client for provider '{provider_id}' not found"
9595
)
9696

97-
# Build redirect URI with provider parameter using BASE_URL
97+
# Build redirect URI with provider in path (Microsoft-compatible)
98+
# Using path parameter instead of query parameter for broader compatibility
9899
base_url = get_config("BASE_URL")
99-
redirect_uri = (
100-
f"{base_url.rstrip('/')}/api/oidc/callback/?provider={provider_id}"
101-
)
100+
redirect_uri = f"{base_url.rstrip('/')}/api/oidc/callback/{provider_id}"
102101

103102
authorization_url = oidc_client.authorize_redirect(redirect_uri)
104103
return authorization_url
@@ -107,13 +106,16 @@ def get(self, args):
107106
class OIDCCallbackResource(Resource):
108107
"""Resource for handling OIDC callback.
109108
110-
Endpoint: /api/oidc/callback/
109+
Endpoint: /api/oidc/callback/ (legacy with query param)
110+
Endpoint: /api/oidc/callback/<provider_id> (path param, Microsoft-compatible)
111111
"""
112112

113113
@limiter.limit("5/minute")
114114
@use_args(
115115
{
116-
"provider": fields.Str(required=True),
116+
"provider": fields.Str(
117+
required=False
118+
), # Optional for backwards compatibility
117119
"tree": fields.Str(required=False),
118120
"code": fields.Str(required=False),
119121
"state": fields.Str(required=False),
@@ -124,12 +126,21 @@ class OIDCCallbackResource(Resource):
124126
location="query",
125127
unknown=EXCLUDE,
126128
)
127-
def get(self, args):
128-
"""Handle OIDC callback and create JWT tokens."""
129+
def get(self, args, provider_id=None):
130+
"""Handle OIDC callback and create JWT tokens.
131+
132+
Args:
133+
args: Query parameters
134+
provider_id: Provider ID from path parameter (if using path-based route)
135+
"""
129136
if not is_oidc_enabled():
130137
abort_with_message(405, "OIDC authentication is not enabled")
131138

132-
provider_id = args.get("provider")
139+
# Support both path parameter (new, Microsoft-compatible) and query parameter (legacy)
140+
provider_id = provider_id or args.get("provider")
141+
142+
if not provider_id:
143+
abort_with_message(400, "Provider ID is required")
133144

134145
# Validate provider is available
135146
available_providers = get_available_oidc_providers()

gramps_webapi/data/apispec.yaml

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ paths:
211211
get:
212212
tags:
213213
- authentication
214-
summary: "Handle OIDC callback and create JWT tokens."
214+
summary: "Handle OIDC callback and create JWT tokens (legacy query parameter format)."
215+
description: "Legacy endpoint for backwards compatibility. New deployments should use /oidc/callback/{provider_id} instead."
215216
operationId: oidcCallback
216217
parameters:
217218
- name: provider
@@ -255,6 +256,55 @@ paths:
255256
500:
256257
description: "Internal Server Error: OIDC client error."
257258

259+
/oidc/callback/{provider_id}:
260+
get:
261+
tags:
262+
- authentication
263+
summary: "Handle OIDC callback and create JWT tokens (path parameter format)."
264+
description: "Path-based endpoint compatible with providers that disallow query parameters in redirect URIs. This is the preferred format for new deployments."
265+
operationId: oidcCallbackProvider
266+
parameters:
267+
- name: provider_id
268+
in: path
269+
required: true
270+
type: string
271+
description: "The OIDC provider ID (e.g., 'google', 'microsoft', 'github')"
272+
example: "microsoft"
273+
- name: code
274+
in: query
275+
required: false
276+
type: string
277+
description: "Authorization code from OIDC provider"
278+
- name: state
279+
in: query
280+
required: false
281+
type: string
282+
description: "State parameter from OIDC provider"
283+
- name: session_state
284+
in: query
285+
required: false
286+
type: string
287+
description: "Session state parameter from OIDC provider"
288+
- name: error
289+
in: query
290+
required: false
291+
type: string
292+
description: "Error parameter from OIDC provider"
293+
- name: error_description
294+
in: query
295+
required: false
296+
type: string
297+
description: "Error description from OIDC provider"
298+
responses:
299+
302:
300+
description: "Redirect: Redirects to frontend with JWT tokens."
301+
400:
302+
description: "Bad Request: Invalid provider or authentication error."
303+
405:
304+
description: "Method Not Allowed: OIDC authentication is not enabled."
305+
500:
306+
description: "Internal Server Error: OIDC client error."
307+
258308
/oidc/logout/:
259309
get:
260310
tags:

tests/test_endpoints/test_oidc.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,134 @@ def test_oidc_callback_missing_code(self):
338338
# The endpoint should handle missing code parameter gracefully
339339
# Implementation will determine exact behavior
340340

341+
@patch("gramps_webapi.api.resources.oidc.is_oidc_enabled", return_value=True)
342+
@patch(
343+
"gramps_webapi.api.resources.oidc.get_available_oidc_providers",
344+
return_value=["microsoft"],
345+
)
346+
@patch("gramps_webapi.api.resources.oidc.create_or_update_oidc_user")
347+
@patch("gramps_webapi.api.resources.oidc.get_name")
348+
@patch("gramps_webapi.api.resources.oidc.get_tree_id")
349+
@patch("gramps_webapi.api.resources.oidc.get_permissions")
350+
@patch("gramps_webapi.api.resources.oidc.is_tree_disabled", return_value=False)
351+
@patch("gramps_webapi.api.resources.oidc.get_tokens")
352+
def test_oidc_callback_path_param_microsoft(
353+
self,
354+
mock_get_tokens,
355+
mock_tree_disabled,
356+
mock_get_permissions,
357+
mock_get_tree_id,
358+
mock_get_name,
359+
mock_create_user,
360+
mock_providers,
361+
mock_oidc_enabled,
362+
):
363+
"""Test OIDC callback with path parameter (Microsoft-compatible URL)."""
364+
# Mock OAuth client and token exchange
365+
mock_oauth = MagicMock()
366+
mock_oidc_client = MagicMock()
367+
mock_oauth.gramps_microsoft = mock_oidc_client
368+
369+
# Mock token and userinfo
370+
mock_token = {"access_token": "test_token"}
371+
mock_userinfo = {
372+
"sub": "user123",
373+
"preferred_username": "testuser",
374+
"email": "[email protected]",
375+
}
376+
mock_oidc_client.authorize_access_token.return_value = mock_token
377+
mock_oidc_client.userinfo.return_value = mock_userinfo
378+
379+
# Mock user creation and token generation
380+
mock_create_user.return_value = "user-guid-123"
381+
mock_get_name.return_value = "testuser"
382+
mock_get_tree_id.return_value = "test_tree"
383+
mock_get_permissions.return_value = {"EditObject"}
384+
mock_get_tokens.return_value = {
385+
"access_token": "jwt_access_token",
386+
"refresh_token": "jwt_refresh_token",
387+
}
388+
389+
# Test path-based URL (no query param for provider)
390+
with patch.dict(
391+
self.client.application.extensions,
392+
{"authlib.integrations.flask_client": mock_oauth},
393+
clear=False,
394+
):
395+
with patch.dict(self.client.application.config, {"TREE": "test_tree"}):
396+
rv = self.client.get(
397+
BASE_URL + "/oidc/callback/microsoft?code=auth_code&state=abc123"
398+
)
399+
self.assertEqual(rv.status_code, 302) # Redirect response
400+
401+
# Verify the flow worked with provider from path
402+
mock_create_user.assert_called_once_with(
403+
mock_userinfo, None, "microsoft"
404+
)
405+
406+
@patch("gramps_webapi.api.resources.oidc.is_oidc_enabled", return_value=True)
407+
@patch(
408+
"gramps_webapi.api.resources.oidc.get_available_oidc_providers",
409+
return_value=["google"],
410+
)
411+
@patch("gramps_webapi.api.resources.oidc.create_or_update_oidc_user")
412+
@patch("gramps_webapi.api.resources.oidc.get_name")
413+
@patch("gramps_webapi.api.resources.oidc.get_tree_id")
414+
@patch("gramps_webapi.api.resources.oidc.get_permissions")
415+
@patch("gramps_webapi.api.resources.oidc.is_tree_disabled", return_value=False)
416+
@patch("gramps_webapi.api.resources.oidc.get_tokens")
417+
def test_oidc_callback_backwards_compatible_query_param(
418+
self,
419+
mock_get_tokens,
420+
mock_tree_disabled,
421+
mock_get_permissions,
422+
mock_get_tree_id,
423+
mock_get_name,
424+
mock_create_user,
425+
mock_providers,
426+
mock_oidc_enabled,
427+
):
428+
"""Test OIDC callback still works with legacy query parameter."""
429+
# Mock OAuth client and token exchange
430+
mock_oauth = MagicMock()
431+
mock_oidc_client = MagicMock()
432+
mock_oauth.gramps_google = mock_oidc_client
433+
434+
# Mock token and userinfo
435+
mock_token = {"access_token": "test_token"}
436+
mock_userinfo = {
437+
"sub": "user123",
438+
"email": "[email protected]",
439+
}
440+
mock_oidc_client.authorize_access_token.return_value = mock_token
441+
mock_oidc_client.userinfo.return_value = mock_userinfo
442+
443+
# Mock user creation and token generation
444+
mock_create_user.return_value = "user-guid-456"
445+
mock_get_name.return_value = "testuser"
446+
mock_get_tree_id.return_value = "test_tree"
447+
mock_get_permissions.return_value = {"ViewPrivate"}
448+
mock_get_tokens.return_value = {
449+
"access_token": "jwt_access_token",
450+
"refresh_token": "jwt_refresh_token",
451+
}
452+
453+
# Test legacy query-param based URL (backwards compatibility)
454+
with patch.dict(
455+
self.client.application.extensions,
456+
{"authlib.integrations.flask_client": mock_oauth},
457+
clear=False,
458+
):
459+
with patch.dict(self.client.application.config, {"TREE": "test_tree"}):
460+
rv = self.client.get(
461+
BASE_URL
462+
+ "/oidc/callback/?provider=google&code=auth_code&state=abc123"
463+
)
464+
self.assertEqual(rv.status_code, 302) # Redirect response
465+
466+
# Verify the flow worked with provider from query param
467+
mock_create_user.assert_called_once_with(mock_userinfo, None, "google")
468+
341469
@patch("gramps_webapi.api.resources.oidc.is_oidc_enabled", return_value=True)
342470
@patch(
343471
"gramps_webapi.api.resources.oidc.get_available_oidc_providers",

0 commit comments

Comments
 (0)