Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6f950fd
OpenID Connect logout support (PP-3726)
tdilauro Feb 20, 2026
182f95f
WIP - experimenting, but problems with endpoint logout
tdilauro Feb 24, 2026
9132cb0
Parse token data directly from bearer token instead of DB lookup
tdilauro Mar 8, 2026
f1922cd
Only fetch config at startup and after config change
tdilauro Mar 9, 2026
0103058
Code review feedback
tdilauro Mar 11, 2026
be33b96
Logout URL updates
tdilauro Mar 17, 2026
4526235
Reorder decorators
tdilauro Mar 18, 2026
898e265
id_token may be absent after token refresh
tdilauro Mar 18, 2026
2181aa3
Tighten exceptions
tdilauro Mar 18, 2026
6869976
DRY credential field validation
tdilauro Mar 18, 2026
bb1e796
Don't conflate unsupport RP-Initiate Logout with missing id token
tdilauro Mar 18, 2026
389550e
Narrow another exception
tdilauro Mar 18, 2026
5c876ca
Add a helper
tdilauro Mar 18, 2026
fd1c940
Dead try/except
tdilauro Mar 18, 2026
e4adcce
Retry configuration if auto-discovery previously failed
tdilauro Mar 18, 2026
deaff05
Fix slow tests
tdilauro Mar 18, 2026
9618e04
A little extra type checking
tdilauro Mar 18, 2026
0a2b3d8
Code clean up
tdilauro Mar 18, 2026
b06e416
Ensure token provider matches actual provider
tdilauro Mar 18, 2026
57ef3b9
Try harder to revoke session
tdilauro Mar 18, 2026
7ba10e8
Drop some more dead code
tdilauro Mar 18, 2026
845f19c
PalaceValueError vs ValueError
tdilauro Mar 18, 2026
3a82d78
Log warning for invalid bearer token
tdilauro Mar 18, 2026
9ca7075
Clean up some stale test data
tdilauro Mar 18, 2026
98e2e52
More detailed PD and add a test
tdilauro Mar 18, 2026
96d13a5
Revoke access token when revoking refresh token
tdilauro Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/palace/manager/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,8 @@ def oidc_authenticate():
# While OIDC allows multiple redirect URIs to be registered (unlike SAML),
# a constant callback URL means we only need to register one URI with each provider.
# Library information is passed via the state parameter and processed in the controller.
@returns_problem_detail
@app.route("/oidc/callback", methods=["GET"])
@returns_problem_detail
def oidc_callback():
return app.manager.oidc_controller.oidc_authentication_callback(
flask.request.args, app.manager._db
Expand All @@ -580,14 +580,15 @@ def oidc_callback():
@has_library
@returns_problem_detail
def oidc_logout():
auth_header = flask.request.headers.get("Authorization", "")
return app.manager.oidc_controller.oidc_logout_initiate(
flask.request.args, app.manager._db
flask.request.args, app.manager._db, auth_header=auth_header
)


# Redirect URI for OIDC logout callback
@returns_problem_detail
@app.route("/oidc/logout_callback", methods=["GET"])
@returns_problem_detail
def oidc_logout_callback():
return app.manager.oidc_controller.oidc_logout_callback(
flask.request.args, app.manager._db
Expand Down Expand Up @@ -622,8 +623,8 @@ def saml_authenticate():
# the IdP will fail this request because the URL mentioned in the request and
# the URL saved in the SP's metadata configured in this IdP will differ.
# Library's name is passed as a part of the relay state and processed in SAMLController.saml_authentication_callback
@returns_problem_detail
@app.route("/saml_callback", methods=["POST"])
@returns_problem_detail
def saml_callback():
return app.manager.saml_controller.saml_authentication_callback(
request, app.manager._db
Expand Down
92 changes: 91 additions & 1 deletion src/palace/manager/integration/patron_auth/oidc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
OIDCAuthSettings,
)
from palace.manager.integration.patron_auth.oidc.util import (
OIDCDiscoveryError,
OIDCUtility,
)
from palace.manager.integration.patron_auth.oidc.validator import (
Expand Down Expand Up @@ -257,9 +258,17 @@ def get_provider_metadata(self, use_cache: bool = True) -> dict[str, Any]:
"jwks_uri": self._settings.jwks_uri,
}

# Add optional userinfo endpoint
# Add optional endpoints
if self._settings.userinfo_endpoint:
self._metadata["userinfo_endpoint"] = self._settings.userinfo_endpoint
if self._settings.end_session_endpoint:
self._metadata["end_session_endpoint"] = str(
self._settings.end_session_endpoint
)
if self._settings.revocation_endpoint:
self._metadata["revocation_endpoint"] = str(
self._settings.revocation_endpoint
)

return self._metadata

Expand Down Expand Up @@ -477,6 +486,87 @@ def build_logout_url(
self.log.info(f"Built logout URL for provider: {end_session_endpoint}")
return logout_url

def _has_metadata_endpoints(self, *keys: str) -> bool:
"""Return True if any of the given keys are present in provider metadata.

Silently returns False if metadata discovery fails.

:param keys: Metadata keys to check (e.g. ``"end_session_endpoint"``)
:return: True if any key has a truthy value in the discovered metadata
"""
try:
metadata = self.get_provider_metadata()
return any(metadata.get(k) for k in keys)
except OIDCDiscoveryError:
return False

def supports_rp_initiated_logout(self) -> bool:
"""Check if the OIDC provider supports RP-Initiated Logout.

Returns True if an end_session_endpoint is available via auto-discovery
or manual configuration.

:return: True if RP-Initiated Logout is supported
"""
return self._has_metadata_endpoints("end_session_endpoint") or bool(
self._settings.end_session_endpoint
)

def supports_logout(self) -> bool:
"""Check if the OIDC provider supports any form of logout.

Returns True if either an end_session_endpoint (RP-Initiated Logout) or
a revocation_endpoint (RFC 7009 token revocation) is available via
auto-discovery or manual configuration.

:return: True if any logout mechanism is supported
"""
return self._has_metadata_endpoints(
"end_session_endpoint", "revocation_endpoint"
) or bool(
self._settings.end_session_endpoint or self._settings.revocation_endpoint
)

def revoke_token(self, token: str, token_type_hint: str = "refresh_token") -> None:
"""Revoke an OAuth 2.0 token via the token revocation endpoint (RFC 7009).

This is a best-effort operation. If the provider does not support revocation
or the call fails, the error is logged and suppressed.

:param token: Token to revoke
:param token_type_hint: Type hint for the token ('refresh_token' or 'access_token')
"""
try:
metadata = self.get_provider_metadata()
except OIDCDiscoveryError:
self.log.debug("Cannot revoke token: failed to get provider metadata")
return

revocation_endpoint = metadata.get("revocation_endpoint")
if not revocation_endpoint:
self.log.debug(
"Provider does not support token revocation (no revocation_endpoint)"
)
return

data: dict[str, str] = {
"token": token,
"token_type_hint": token_type_hint,
}
auth = self._prepare_token_endpoint_auth(data)

try:
HTTP.post_with_timeout(
str(revocation_endpoint),
data=data,
auth=auth,
headers={"Accept": "application/json"},
allowed_response_codes=["2xx"],
)
self.log.info(f"Successfully revoked {token_type_hint}")
except (RequestNetworkException, RequestException):
self.log.warning("Token revocation failed (non-critical)", exc_info=True)

def validate_id_token_hint(self, id_token: str) -> dict[str, Any]:
"""Validate ID token hint for logout.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,25 @@ class OIDCAuthSettings(AuthProviderSettings, LoggerMixin):
end_session_endpoint: Annotated[
HttpUrl | None,
FormMetadata(
label=_("End Session Endpoint (Optional)"),
label=_("End Session Endpoint (Manual Mode - Optional)"),
description=_(
"OIDC provider's end session endpoint URL for RP-Initiated Logout. "
"Optional - enables logout functionality if supported by provider. "
"Automatically discovered if Issuer URL is provided. "
"Example: https://accounts.google.com/o/oauth2/revoke"
"Example: https://login.microsoftonline.com/common/oauth2/v2.0/logout"
),
),
] = None

revocation_endpoint: Annotated[
HttpUrl | None,
FormMetadata(
label=_("Revocation Endpoint (Manual Mode - Optional)"),
description=_(
"OIDC provider's token revocation endpoint URL (RFC 7009). "
"Used to revoke access and refresh tokens on logout. "
"Automatically discovered if Issuer URL is provided. "
"Example: https://oauth2.googleapis.com/revoke"
),
),
] = None
Expand Down Expand Up @@ -417,6 +430,7 @@ def validate_configuration_mode(self) -> OIDCAuthSettings:
"jwks_uri",
"userinfo_endpoint",
"end_session_endpoint",
"revocation_endpoint",
)
@classmethod
def validate_url_fields(
Expand Down
Loading
Loading