first commit

This commit is contained in:
linkitapps
2026-09-14 23:22:10 +09:00
commit 9f9c8fb69b
4207 changed files with 1333187 additions and 0 deletions
@@ -0,0 +1,19 @@
"""authlib.oidc.rpinitiated.
~~~~~~~~~~~~~~~~~~~~~~~~~~~
OpenID Connect RP-Initiated Logout 1.0 Implementation.
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
"""
from .discovery import OpenIDProviderMetadata
from .end_session import EndSessionEndpoint
from .end_session import EndSessionRequest
from .registration import ClientMetadataClaims
__all__ = [
"EndSessionEndpoint",
"EndSessionRequest",
"ClientMetadataClaims",
"OpenIDProviderMetadata",
]
@@ -0,0 +1,13 @@
from authlib.common.security import is_secure_transport
class OpenIDProviderMetadata(dict):
REGISTRY_KEYS = ["end_session_endpoint"]
def validate_end_session_endpoint(self):
# rpinitiated §2.1: "end_session_endpoint - URL at the OP to which an
# RP can perform a redirect to request that the End-User be logged out
# at the OP. This URL MUST use the https scheme."
url = self.get("end_session_endpoint")
if url and not is_secure_transport(url):
raise ValueError('"end_session_endpoint" MUST use "https" scheme')
@@ -0,0 +1,285 @@
"""OpenID Connect RP-Initiated Logout 1.0 implementation.
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
from typing import TYPE_CHECKING
from typing import Any
from joserfc import jwt
from joserfc.errors import JoseError
from joserfc.jwk import KeySet
from joserfc.jws import JWSRegistry
from authlib.common.urls import add_params_to_uri
from authlib.oauth2.rfc6749.endpoint import Endpoint
from authlib.oauth2.rfc6749.endpoint import EndpointRequest
from authlib.oauth2.rfc6749.errors import InvalidRequestError
if TYPE_CHECKING:
from authlib.oauth2.rfc6749.requests import OAuth2Request
class _NonExpiringClaimsRegistry(jwt.JWTClaimsRegistry):
"""Claims registry that skips expiration validation."""
# rpinitiated §2: "The OP SHOULD accept ID Tokens when the RP identified by the
# ID Token's aud claim and/or sid claim has a current session or had a
# recent session at the OP, even when the exp time has passed."
def validate_exp(self, value: int) -> None:
pass
@dataclass
class EndSessionRequest(EndpointRequest):
"""Validated end session request data.
This object is returned by :meth:`EndSessionEndpoint.validate_request`
and contains all the validated information from the logout request.
"""
id_token_claims: dict | None = field(default=None, repr=False)
redirect_uri: str | None = None
logout_hint: str | None = None
ui_locales: str | None = None
@property
def needs_confirmation(self) -> bool:
"""Whether user confirmation is recommended before logout."""
# rpinitiated §6: "Logout requests without a valid id_token_hint value are a
# potential means of denial of service; therefore, OPs should obtain
# explicit confirmation from the End-User before acting upon them."
return self.id_token_claims is None
class EndSessionEndpoint(Endpoint):
"""OpenID Connect RP-Initiated Logout endpoint.
This endpoint follows a two-phase pattern for interactive flows:
1. Call ``server.validate_endpoint_request("end_session")`` to validate
the request and get an :class:`EndSessionRequest`
2. Check ``end_session_request.needs_confirmation`` and show UI if needed
3. Call ``server.create_endpoint_response("end_session", end_session_request)``
to execute logout and create the response
Example usage::
class MyEndSessionEndpoint(EndSessionEndpoint):
def get_server_jwks(self):
return load_jwks()
def end_session(self, end_session_request):
session.clear()
server.register_endpoint(MyEndSessionEndpoint)
@app.route("/logout", methods=["GET", "POST"])
def logout():
try:
req = server.validate_endpoint_request("end_session")
except OAuth2Error as error:
return server.handle_error_response(None, error)
if req.needs_confirmation and request.method == "GET":
return render_template("confirm_logout.html", client=req.client)
return server.create_endpoint_response(
"end_session", req
) or render_template("logged_out.html")
For non-interactive usage (no confirmation page), use the standard pattern::
@app.route("/logout", methods=["GET", "POST"])
def logout():
return server.create_endpoint_response("end_session") or render_template(
"logged_out.html"
)
"""
ENDPOINT_NAME = "end_session"
def validate_request(self, request: OAuth2Request) -> EndSessionRequest:
"""Validate an end session request.
:param request: The OAuth2Request to validate
:returns: EndSessionRequest with validated data
:raises InvalidRequestError: If validation fails
"""
data = request.payload.data
id_token_hint = data.get("id_token_hint")
client_id = data.get("client_id")
post_logout_redirect_uri = data.get("post_logout_redirect_uri")
state = data.get("state")
logout_hint = data.get("logout_hint")
ui_locales = data.get("ui_locales")
# rpinitiated §2: "When an id_token_hint parameter is present, the OP MUST
# validate that it was the issuer of the ID Token."
id_token_claims = None
if id_token_hint:
id_token_claims = self._validate_id_token_hint(id_token_hint)
# Resolve client
client = None
if client_id:
client = self.server.query_client(client_id)
elif id_token_claims:
client = self.resolve_client_from_id_token_claims(id_token_claims)
# rpinitiated §2: "When both client_id and id_token_hint are present, the OP
# MUST verify that the Client Identifier matches the one used as the
# audience of the ID Token."
if client_id and id_token_claims:
aud = id_token_claims.get("aud")
aud_list = [aud] if isinstance(aud, str) else (aud or [])
if client_id not in aud_list:
raise InvalidRequestError("'client_id' does not match 'aud' claim")
# rpinitiated §3: "The OP MUST NOT perform post-logout redirection if
# the post_logout_redirect_uri value supplied does not exactly match
# one of the previously registered post_logout_redirect_uris values."
redirect_uri = None
if (
post_logout_redirect_uri
and client
and self._is_valid_post_logout_redirect_uri(
client, post_logout_redirect_uri
)
and (
id_token_claims
or self.is_post_logout_redirect_uri_legitimate(
request, post_logout_redirect_uri, client, logout_hint
)
)
):
redirect_uri = post_logout_redirect_uri
# rpinitiated §3: "If the post_logout_redirect_uri value is provided
# and the preceding conditions are met, the OP MUST include the
# state value if the RP's initial Logout Request included state."
if state:
redirect_uri = add_params_to_uri(redirect_uri, {"state": state})
return EndSessionRequest(
request=request,
client=client,
id_token_claims=id_token_claims,
redirect_uri=redirect_uri,
logout_hint=logout_hint,
ui_locales=ui_locales,
)
def create_response(
self, validated_request: EndSessionRequest
) -> tuple[int, Any, list[tuple[str, str]]] | None:
"""Create the end session HTTP response.
Executes the logout via :meth:`end_session`, then returns a redirect
response if a valid redirect_uri is present, or None to let the
application provide its own response.
:param validated_request: The validated EndSessionRequest
:returns: Tuple of (status_code, body, headers) for redirect, or None
"""
req: EndSessionRequest = validated_request # type: ignore[assignment]
self.end_session(req)
if req.redirect_uri:
return 302, "", [("Location", req.redirect_uri)]
return None
def _validate_id_token_hint(self, id_token_hint: str) -> dict:
"""Validate that the OP was the issuer of the ID Token."""
# rpinitiated §2: "When an id_token_hint parameter is present, the OP MUST
# validate that it was the issuer of the ID Token."
# This is done by verifying the signature against the server's JWKS.
jwks = self.get_server_jwks()
if isinstance(jwks, dict):
jwks = KeySet.import_key_set(jwks)
# rpinitiated §4: "When the OP detects errors in the RP-Initiated
# Logout request, the OP MUST not perform post-logout redirection."
try:
token = jwt.decode(id_token_hint, jwks, algorithms=self.get_algorithms())
claims_registry = _NonExpiringClaimsRegistry(nbf={"essential": False})
claims_registry.validate(token.claims)
except JoseError as exc:
raise InvalidRequestError(exc.description) from exc
return dict(token.claims)
def resolve_client_from_id_token_claims(self, id_token_claims: dict) -> Any | None:
"""Resolve client from id_token aud claim.
When aud is a single string, resolves the client directly.
When aud is a list, returns None (ambiguous case).
Override for custom resolution logic.
"""
aud = id_token_claims.get("aud")
if isinstance(aud, str):
return self.server.query_client(aud)
return None
def _is_valid_post_logout_redirect_uri(
self, client, post_logout_redirect_uri: str
) -> bool:
"""Check if post_logout_redirect_uri is registered for the client."""
registered_uris = client.client_metadata.get("post_logout_redirect_uris", [])
return post_logout_redirect_uri in registered_uris
def is_post_logout_redirect_uri_legitimate(
self,
request: OAuth2Request,
post_logout_redirect_uri: str,
client,
logout_hint: str | None,
) -> bool:
"""Confirm redirect_uri legitimacy when no id_token_hint is provided.
Override if you have alternative confirmation mechanisms, e.g.::
def is_post_logout_redirect_uri_legitimate(self, ...):
return client and client.is_trusted
By default returns False (no redirection without id_token_hint).
"""
# rpinitiated §3: "if it is not supplied with post_logout_redirect_uri,
# the OP MUST NOT perform post-logout redirection unless the OP has
# other means of confirming the legitimacy"
return False
def get_server_jwks(self) -> dict | KeySet:
"""Return the server's JSON Web Key Set for validating ID tokens."""
raise NotImplementedError()
def get_algorithms(self) -> list[str]:
"""Return the list of allowed algorithms for ID token validation.
By default, returns all algorithms compatible with the keys in the JWKS.
Override to restrict to specific algorithms.
"""
jwks = self.get_server_jwks()
if isinstance(jwks, dict):
jwks = KeySet.import_key_set(jwks)
return [alg.name for alg in JWSRegistry.filter_algorithms(jwks)]
def end_session(self, end_session_request: EndSessionRequest) -> None:
"""Terminate the user's session.
Implement this method to perform the actual logout logic,
such as clearing session data, revoking tokens, etc.
Use ``end_session_request.logout_hint`` to help identify the user
(e.g. email, username) when no ``id_token_hint`` is provided.
:param end_session_request: The validated EndSessionRequest
"""
raise NotImplementedError()
@@ -0,0 +1,57 @@
"""Client metadata for OpenID Connect RP-Initiated Logout 1.0.
https://openid.net/specs/openid-connect-rpinitiated-1_0.html
"""
from joserfc.errors import InvalidClaimError
from authlib.common.security import is_secure_transport
from authlib.common.urls import is_valid_url
from authlib.oauth2.claims import BaseClaims
class ClientMetadataClaims(BaseClaims):
"""Client metadata for OpenID Connect RP-Initiated Logout 1.0.
This can be used with :ref:`specs/rfc7591` and :ref:`specs/rfc7592` endpoints::
server.register_endpoint(
ClientRegistrationEndpoint(
claims_classes=[
rfc7591.ClientMetadataClaims,
oidc.registration.ClientMetadataClaims,
oidc.rpinitiated.ClientMetadataClaims,
]
)
)
"""
REGISTERED_CLAIMS = [
"post_logout_redirect_uris",
]
def validate(self, now=None, leeway=0):
super().validate(now, leeway)
self._validate_post_logout_redirect_uris()
def _validate_post_logout_redirect_uris(self):
# rpinitiated §3.1: "post_logout_redirect_uris - Array of URLs supplied
# by the RP to which it MAY request that the End-User's User Agent be
# redirected using the post_logout_redirect_uri parameter after a
# logout has been performed. These URLs SHOULD use the https scheme
# [...]; however, they MAY use the http scheme, provided that the
# Client Type is confidential."
uris = self.get("post_logout_redirect_uris")
if not uris:
return
is_public = self.get("token_endpoint_auth_method") == "none"
for uri in uris:
if not is_valid_url(uri):
raise InvalidClaimError("post_logout_redirect_uris")
if is_public and not is_secure_transport(uri):
raise ValueError(
'"post_logout_redirect_uris" MUST use "https" scheme for public clients'
)