providers/oauth2: token revoke (#3077)
This commit is contained in:
		| @ -95,38 +95,45 @@ class TokenIntrospectionError(OAuth2Error): | ||||
| class AuthorizeError(OAuth2Error): | ||||
|     """General Authorization Errors""" | ||||
|  | ||||
|     _errors = { | ||||
|     errors = { | ||||
|         # OAuth2 errors. | ||||
|         # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 | ||||
|         "invalid_request": "The request is otherwise malformed", | ||||
|         "unauthorized_client": "The client is not authorized to request an " | ||||
|         "authorization code using this method", | ||||
|         "access_denied": "The resource owner or authorization server denied " "the request", | ||||
|         "unsupported_response_type": "The authorization server does not " | ||||
|         "support obtaining an authorization code " | ||||
|         "using this method", | ||||
|         "invalid_scope": "The requested scope is invalid, unknown, or " "malformed", | ||||
|         "unauthorized_client": ( | ||||
|             "The client is not authorized to request an authorization code using this method" | ||||
|         ), | ||||
|         "access_denied": "The resource owner or authorization server denied the request", | ||||
|         "unsupported_response_type": ( | ||||
|             "The authorization server does not support obtaining an authorization code " | ||||
|             "using this method" | ||||
|         ), | ||||
|         "invalid_scope": "The requested scope is invalid, unknown, or malformed", | ||||
|         "server_error": "The authorization server encountered an error", | ||||
|         "temporarily_unavailable": "The authorization server is currently " | ||||
|         "unable to handle the request due to a " | ||||
|         "temporary overloading or maintenance of " | ||||
|         "the server", | ||||
|         "temporarily_unavailable": ( | ||||
|             "The authorization server is currently unable to handle the request due to a " | ||||
|             "temporary overloading or maintenance of the server" | ||||
|         ), | ||||
|         # OpenID errors. | ||||
|         # http://openid.net/specs/openid-connect-core-1_0.html#AuthError | ||||
|         "interaction_required": "The Authorization Server requires End-User " | ||||
|         "interaction of some form to proceed", | ||||
|         "login_required": "The Authorization Server requires End-User " "authentication", | ||||
|         "account_selection_required": "The End-User is required to select a " | ||||
|         "session at the Authorization Server", | ||||
|         "consent_required": "The Authorization Server requires End-User" "consent", | ||||
|         "invalid_request_uri": "The request_uri in the Authorization Request " | ||||
|         "returns an error or contains invalid data", | ||||
|         "invalid_request_object": "The request parameter contains an invalid " "Request Object", | ||||
|         "request_not_supported": "The provider does not support use of the " "request parameter", | ||||
|         "request_uri_not_supported": "The provider does not support use of the " | ||||
|         "request_uri parameter", | ||||
|         "registration_not_supported": "The provider does not support use of " | ||||
|         "the registration parameter", | ||||
|         "interaction_required": ( | ||||
|             "The Authorization Server requires End-User interaction of some form to proceed" | ||||
|         ), | ||||
|         "login_required": "The Authorization Server requires End-User authentication", | ||||
|         "account_selection_required": ( | ||||
|             "The End-User is required to select a session at the Authorization Server" | ||||
|         ), | ||||
|         "consent_required": "The Authorization Server requires End-Userconsent", | ||||
|         "invalid_request_uri": ( | ||||
|             "The request_uri in the Authorization Request returns an error or contains invalid data" | ||||
|         ), | ||||
|         "invalid_request_object": "The request parameter contains an invalid Request Object", | ||||
|         "request_not_supported": "The provider does not support use of the request parameter", | ||||
|         "request_uri_not_supported": ( | ||||
|             "The provider does not support use of the request_uri parameter" | ||||
|         ), | ||||
|         "registration_not_supported": ( | ||||
|             "The provider does not support use of the registration parameter" | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|     def __init__( | ||||
| @ -138,7 +145,7 @@ class AuthorizeError(OAuth2Error): | ||||
|     ): | ||||
|         super().__init__() | ||||
|         self.error = error | ||||
|         self.description = self._errors[error] | ||||
|         self.description = self.errors[error] | ||||
|         self.redirect_uri = redirect_uri | ||||
|         self.grant_type = grant_type | ||||
|         self.state = state | ||||
| @ -170,19 +177,25 @@ class TokenError(OAuth2Error): | ||||
|  | ||||
|     errors = { | ||||
|         "invalid_request": "The request is otherwise malformed", | ||||
|         "invalid_client": "Client authentication failed (e.g., unknown client, " | ||||
|         "no client authentication included, or unsupported " | ||||
|         "authentication method)", | ||||
|         "invalid_grant": "The provided authorization grant or refresh token is " | ||||
|         "invalid, expired, revoked, does not match the " | ||||
|         "redirection URI used in the authorization request, " | ||||
|         "or was issued to another client", | ||||
|         "unauthorized_client": "The authenticated client is not authorized to " | ||||
|         "use this authorization grant type", | ||||
|         "unsupported_grant_type": "The authorization grant type is not " | ||||
|         "supported by the authorization server", | ||||
|         "invalid_scope": "The requested scope is invalid, unknown, malformed, " | ||||
|         "or exceeds the scope granted by the resource owner", | ||||
|         "invalid_client": ( | ||||
|             "Client authentication failed (e.g., unknown client, no client authentication " | ||||
|             "included, or unsupported authentication method)" | ||||
|         ), | ||||
|         "invalid_grant": ( | ||||
|             "The provided authorization grant or refresh token is invalid, expired, revoked, " | ||||
|             "does not match the redirection URI used in the authorization request, " | ||||
|             "or was issued to another client" | ||||
|         ), | ||||
|         "unauthorized_client": ( | ||||
|             "The authenticated client is not authorized to use this authorization grant type" | ||||
|         ), | ||||
|         "unsupported_grant_type": ( | ||||
|             "The authorization grant type is not supported by the authorization server" | ||||
|         ), | ||||
|         "invalid_scope": ( | ||||
|             "The requested scope is invalid, unknown, malformed, or exceeds the scope " | ||||
|             "granted by the resource owner" | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|     def __init__(self, error): | ||||
| @ -191,17 +204,39 @@ class TokenError(OAuth2Error): | ||||
|         self.description = self.errors[error] | ||||
|  | ||||
|  | ||||
| class TokenRevocationError(OAuth2Error): | ||||
|     """ | ||||
|     Specific to the revocation endpoint. | ||||
|     See https://tools.ietf.org/html/rfc7662 | ||||
|     """ | ||||
|  | ||||
|     errors = TokenError.errors | { | ||||
|         "unsupported_token_type": ( | ||||
|             "The authorization server does not support the revocation of the presented " | ||||
|             "token type.  That is, the client tried to revoke an access token on a server not" | ||||
|             "supporting this feature." | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     def __init__(self, error: str): | ||||
|         super().__init__() | ||||
|         self.error = error | ||||
|         self.description = self.errors[error] | ||||
|  | ||||
|  | ||||
| class BearerTokenError(OAuth2Error): | ||||
|     """ | ||||
|     OAuth2 errors. | ||||
|     https://tools.ietf.org/html/rfc6750#section-3.1 | ||||
|     """ | ||||
|  | ||||
|     _errors = { | ||||
|     errors = { | ||||
|         "invalid_request": ("The request is otherwise malformed", 400), | ||||
|         "invalid_token": ( | ||||
|             "The access token provided is expired, revoked, malformed, " | ||||
|             "or invalid for other reasons", | ||||
|             ( | ||||
|                 "The access token provided is expired, revoked, malformed, " | ||||
|                 "or invalid for other reasons" | ||||
|             ), | ||||
|             401, | ||||
|         ), | ||||
|         "insufficient_scope": ( | ||||
| @ -213,6 +248,6 @@ class BearerTokenError(OAuth2Error): | ||||
|     def __init__(self, code): | ||||
|         super().__init__() | ||||
|         self.code = code | ||||
|         error_tuple = self._errors.get(code, ("", "")) | ||||
|         error_tuple = self.errors.get(code, ("", "")) | ||||
|         self.description = error_tuple[0] | ||||
|         self.status = error_tuple[1] | ||||
|  | ||||
							
								
								
									
										98
									
								
								authentik/providers/oauth2/tests/test_introspect.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								authentik/providers/oauth2/tests/test_introspect.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,98 @@ | ||||
| """Test introspect view""" | ||||
| import json | ||||
| from base64 import b64encode | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
|  | ||||
|  | ||||
| class TesOAuth2Introspection(OAuthTestCase): | ||||
|     """Test introspect view""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.provider: OAuth2Provider = OAuth2Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|             client_id=generate_id(), | ||||
|             client_secret=generate_key(), | ||||
|             authorization_flow=create_test_flow(), | ||||
|             redirect_uris="", | ||||
|             signing_key=create_test_cert(), | ||||
|         ) | ||||
|         self.app = Application.objects.create( | ||||
|             name=generate_id(), slug=generate_id(), provider=self.provider | ||||
|         ) | ||||
|         self.app.save() | ||||
|         self.user = create_test_admin_user() | ||||
|         self.token: RefreshToken = RefreshToken.objects.create( | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             access_token=generate_id(), | ||||
|             refresh_token=generate_id(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
|                     IDToken("foo", "bar"), | ||||
|                 ) | ||||
|             ), | ||||
|         ) | ||||
|         self.auth = b64encode( | ||||
|             f"{self.provider.client_id}:{self.provider.client_secret}".encode() | ||||
|         ).decode() | ||||
|  | ||||
|     def test_introspect(self): | ||||
|         """Test introspect""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-introspection"), | ||||
|             HTTP_AUTHORIZATION=f"Basic {self.auth}", | ||||
|             data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             res.content.decode(), | ||||
|             { | ||||
|                 "aud": None, | ||||
|                 "sub": "bar", | ||||
|                 "exp": None, | ||||
|                 "iat": None, | ||||
|                 "iss": "foo", | ||||
|                 "active": True, | ||||
|                 "client_id": self.provider.client_id, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_introspect_invalid_token(self): | ||||
|         """Test introspect (invalid token)""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-introspection"), | ||||
|             HTTP_AUTHORIZATION=f"Basic {self.auth}", | ||||
|             data={"token": generate_id(), "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             res.content.decode(), | ||||
|             { | ||||
|                 "active": False, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_introspect_invalid_auth(self): | ||||
|         """Test introspect (invalid auth)""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-introspection"), | ||||
|             HTTP_AUTHORIZATION="Basic qwerqrwe", | ||||
|             data={"token": generate_id(), "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             res.content.decode(), | ||||
|             { | ||||
|                 "active": False, | ||||
|             }, | ||||
|         ) | ||||
							
								
								
									
										74
									
								
								authentik/providers/oauth2/tests/test_revoke.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								authentik/providers/oauth2/tests/test_revoke.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | ||||
| """Test revoke view""" | ||||
| import json | ||||
| from base64 import b64encode | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
|  | ||||
|  | ||||
| class TesOAuth2Revoke(OAuthTestCase): | ||||
|     """Test revoke view""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.provider: OAuth2Provider = OAuth2Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|             client_id=generate_id(), | ||||
|             client_secret=generate_key(), | ||||
|             authorization_flow=create_test_flow(), | ||||
|             redirect_uris="", | ||||
|             signing_key=create_test_cert(), | ||||
|         ) | ||||
|         self.app = Application.objects.create( | ||||
|             name=generate_id(), slug=generate_id(), provider=self.provider | ||||
|         ) | ||||
|         self.app.save() | ||||
|         self.user = create_test_admin_user() | ||||
|         self.token: RefreshToken = RefreshToken.objects.create( | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             access_token=generate_id(), | ||||
|             refresh_token=generate_id(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
|                     IDToken("foo", "bar"), | ||||
|                 ) | ||||
|             ), | ||||
|         ) | ||||
|         self.auth = b64encode( | ||||
|             f"{self.provider.client_id}:{self.provider.client_secret}".encode() | ||||
|         ).decode() | ||||
|  | ||||
|     def test_revoke(self): | ||||
|         """Test revoke""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-revoke"), | ||||
|             HTTP_AUTHORIZATION=f"Basic {self.auth}", | ||||
|             data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|  | ||||
|     def test_revoke_invalid(self): | ||||
|         """Test revoke (invalid token)""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-revoke"), | ||||
|             HTTP_AUTHORIZATION=f"Basic {self.auth}", | ||||
|             data={"token": self.token.refresh_token + "foo", "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|  | ||||
|     def test_revoke_invalid_auth(self): | ||||
|         """Test revoke (invalid auth)""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token-revoke"), | ||||
|             HTTP_AUTHORIZATION="Basic fqewr", | ||||
|             data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 401) | ||||
| @ -19,9 +19,9 @@ class TestUserinfo(OAuthTestCase): | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         ObjectManager().run() | ||||
|         self.app = Application.objects.create(name="test", slug="test") | ||||
|         self.app = Application.objects.create(name=generate_id(), slug=generate_id()) | ||||
|         self.provider: OAuth2Provider = OAuth2Provider.objects.create( | ||||
|             name="test", | ||||
|             name=generate_id(), | ||||
|             client_id=generate_id(), | ||||
|             client_secret=generate_key(), | ||||
|             authorization_flow=create_test_flow(), | ||||
|  | ||||
| @ -10,6 +10,7 @@ from authentik.providers.oauth2.views.introspection import TokenIntrospectionVie | ||||
| from authentik.providers.oauth2.views.jwks import JWKSView | ||||
| from authentik.providers.oauth2.views.provider import ProviderInfoView | ||||
| from authentik.providers.oauth2.views.token import TokenView | ||||
| from authentik.providers.oauth2.views.token_revoke import TokenRevokeView | ||||
| from authentik.providers.oauth2.views.userinfo import UserInfoView | ||||
|  | ||||
| urlpatterns = [ | ||||
| @ -29,6 +30,11 @@ urlpatterns = [ | ||||
|         csrf_exempt(TokenIntrospectionView.as_view()), | ||||
|         name="token-introspection", | ||||
|     ), | ||||
|     path( | ||||
|         "revoke/", | ||||
|         csrf_exempt(TokenRevokeView.as_view()), | ||||
|         name="token-revoke", | ||||
|     ), | ||||
|     path( | ||||
|         "<slug:application_slug>/end-session/", | ||||
|         RedirectView.as_view(pattern_name="authentik_core:if-session-end"), | ||||
|  | ||||
| @ -12,7 +12,7 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.providers.oauth2.errors import BearerTokenError | ||||
| from authentik.providers.oauth2.models import RefreshToken | ||||
| from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -172,6 +172,20 @@ def protected_resource_view(scopes: list[str]): | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def authenticate_provider(request: HttpRequest) -> Optional[OAuth2Provider]: | ||||
|     """Attempt to authenticate via Basic auth of client_id:client_secret""" | ||||
|     client_id, client_secret = extract_client_auth(request) | ||||
|     if client_id == client_secret == "": | ||||
|         return None | ||||
|     provider: Optional[OAuth2Provider] = OAuth2Provider.objects.filter(client_id=client_id).first() | ||||
|     if not provider: | ||||
|         return None | ||||
|     if client_id != provider.client_id or client_secret != provider.client_secret: | ||||
|         LOGGER.debug("(basic) Provider for basic auth does not exist") | ||||
|         return None | ||||
|     return provider | ||||
|  | ||||
|  | ||||
| class HttpResponseRedirectScheme(HttpResponseRedirect): | ||||
|     """HTTP Response to redirect, can be to a non-http scheme""" | ||||
|  | ||||
|  | ||||
| @ -7,11 +7,7 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.providers.oauth2.errors import TokenIntrospectionError | ||||
| from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken | ||||
| from authentik.providers.oauth2.utils import ( | ||||
|     TokenResponse, | ||||
|     extract_access_token, | ||||
|     extract_client_auth, | ||||
| ) | ||||
| from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -21,8 +17,8 @@ class TokenIntrospectionParams: | ||||
|     """Parameters for Token Introspection""" | ||||
|  | ||||
|     token: RefreshToken | ||||
|     provider: OAuth2Provider | ||||
|  | ||||
|     provider: OAuth2Provider = field(init=False) | ||||
|     id_token: IDToken = field(init=False) | ||||
|  | ||||
|     def __post_init__(self): | ||||
| @ -30,7 +26,6 @@ class TokenIntrospectionParams: | ||||
|             LOGGER.debug("Token is not valid") | ||||
|             raise TokenIntrospectionError() | ||||
|  | ||||
|         self.provider = self.token.provider | ||||
|         self.id_token = self.token.id_token | ||||
|  | ||||
|         if not self.token.id_token: | ||||
| @ -40,30 +35,6 @@ class TokenIntrospectionParams: | ||||
|             ) | ||||
|             raise TokenIntrospectionError() | ||||
|  | ||||
|     def authenticate_basic(self, request: HttpRequest) -> bool: | ||||
|         """Attempt to authenticate via Basic auth of client_id:client_secret""" | ||||
|         client_id, client_secret = extract_client_auth(request) | ||||
|         if client_id == client_secret == "": | ||||
|             return False | ||||
|         if client_id != self.provider.client_id or client_secret != self.provider.client_secret: | ||||
|             LOGGER.debug("(basic) Provider for basic auth does not exist") | ||||
|             raise TokenIntrospectionError() | ||||
|         return True | ||||
|  | ||||
|     def authenticate_bearer(self, request: HttpRequest) -> bool: | ||||
|         """Attempt to authenticate via token sent as bearer header""" | ||||
|         body_token = extract_access_token(request) | ||||
|         if not body_token: | ||||
|             return False | ||||
|         tokens = RefreshToken.objects.filter(access_token=body_token).select_related("provider") | ||||
|         if not tokens.exists(): | ||||
|             LOGGER.debug("(bearer) Token does not exist") | ||||
|             raise TokenIntrospectionError() | ||||
|         if tokens.first().provider != self.provider: | ||||
|             LOGGER.debug("(bearer) Token providers don't match") | ||||
|             raise TokenIntrospectionError() | ||||
|         return True | ||||
|  | ||||
|     @staticmethod | ||||
|     def from_request(request: HttpRequest) -> "TokenIntrospectionParams": | ||||
|         """Extract required Parameters from HTTP Request""" | ||||
| @ -75,19 +46,17 @@ class TokenIntrospectionParams: | ||||
|             LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) | ||||
|             raise TokenIntrospectionError() | ||||
|  | ||||
|         provider = authenticate_provider(request) | ||||
|         if not provider: | ||||
|             raise TokenIntrospectionError | ||||
|  | ||||
|         try: | ||||
|             token: RefreshToken = RefreshToken.objects.select_related("provider").get( | ||||
|                 **token_filter | ||||
|             ) | ||||
|             token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter) | ||||
|         except RefreshToken.DoesNotExist: | ||||
|             LOGGER.debug("Token does not exist", token=raw_token) | ||||
|             raise TokenIntrospectionError() | ||||
|  | ||||
|         params = TokenIntrospectionParams(token=token) | ||||
|         if not any([params.authenticate_basic(request), params.authenticate_bearer(request)]): | ||||
|             LOGGER.warning("Not authenticated") | ||||
|             raise TokenIntrospectionError() | ||||
|         return params | ||||
|         return TokenIntrospectionParams(token=token, provider=provider) | ||||
|  | ||||
|  | ||||
| class TokenIntrospectionView(View): | ||||
|  | ||||
| @ -58,6 +58,9 @@ class ProviderInfoView(View): | ||||
|             "introspection_endpoint": self.request.build_absolute_uri( | ||||
|                 reverse("authentik_providers_oauth2:token-introspection") | ||||
|             ), | ||||
|             "revocation_endpoint": self.request.build_absolute_uri( | ||||
|                 reverse("authentik_providers_oauth2:token-revoke") | ||||
|             ), | ||||
|             "response_types_supported": [ | ||||
|                 ResponseTypes.CODE, | ||||
|                 ResponseTypes.ID_TOKEN, | ||||
|  | ||||
							
								
								
									
										66
									
								
								authentik/providers/oauth2/views/token_revoke.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								authentik/providers/oauth2/views/token_revoke.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | ||||
| """Token revocation endpoint""" | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from django.http import Http404, HttpRequest, HttpResponse | ||||
| from django.views import View | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.providers.oauth2.errors import TokenRevocationError | ||||
| from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken | ||||
| from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class TokenRevocationParams: | ||||
|     """Parameters for Token Revocation""" | ||||
|  | ||||
|     token: RefreshToken | ||||
|     provider: OAuth2Provider | ||||
|  | ||||
|     @staticmethod | ||||
|     def from_request(request: HttpRequest) -> "TokenRevocationParams": | ||||
|         """Extract required Parameters from HTTP Request""" | ||||
|         raw_token = request.POST.get("token") | ||||
|         token_type_hint = request.POST.get("token_type_hint", "access_token") | ||||
|         token_filter = {token_type_hint: raw_token} | ||||
|  | ||||
|         if token_type_hint not in ["access_token", "refresh_token"]: | ||||
|             LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) | ||||
|             raise TokenRevocationError("unsupported_token_type") | ||||
|  | ||||
|         provider = authenticate_provider(request) | ||||
|         if not provider: | ||||
|             raise TokenRevocationError("invalid_client") | ||||
|  | ||||
|         try: | ||||
|             token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter) | ||||
|         except RefreshToken.DoesNotExist: | ||||
|             LOGGER.debug("Token does not exist", token=raw_token) | ||||
|             raise Http404 | ||||
|  | ||||
|         return TokenRevocationParams(token=token, provider=provider) | ||||
|  | ||||
|  | ||||
| class TokenRevokeView(View): | ||||
|     """Token revoke endpoint | ||||
|     https://datatracker.ietf.org/doc/html/rfc7009""" | ||||
|  | ||||
|     token: RefreshToken | ||||
|     params: TokenRevocationParams | ||||
|     provider: OAuth2Provider | ||||
|  | ||||
|     def post(self, request: HttpRequest) -> HttpResponse: | ||||
|         """Revocation handler""" | ||||
|         try: | ||||
|             self.params = TokenRevocationParams.from_request(request) | ||||
|  | ||||
|             self.params.token.delete() | ||||
|  | ||||
|             return TokenResponse(data={}, status=200) | ||||
|         except TokenRevocationError as exc: | ||||
|             return TokenResponse(exc.create_dict(), status=401) | ||||
|         except Http404: | ||||
|             # Token not found should return a HTTP 200 according to the specs | ||||
|             return TokenResponse(data={}, status=200) | ||||
| @ -142,6 +142,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||
|         self.driver.get("http://localhost:9009") | ||||
|         self.login() | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) | ||||
|         self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|  | ||||
|         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) | ||||
| @ -206,6 +207,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||
|         ).click() | ||||
|  | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) | ||||
|         self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|  | ||||
|         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) | ||||
|  | ||||
| @ -140,10 +140,10 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | ||||
|         self.container = self.setup_client() | ||||
|  | ||||
|         self.driver.get("http://localhost:9009/implicit/") | ||||
|         sleep(2) | ||||
|         self.wait.until(ec.title_contains("authentik")) | ||||
|         self.login() | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) | ||||
|         sleep(1) | ||||
|         self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|         self.assertEqual(body["profile"]["nickname"], self.user.username) | ||||
|         self.assertEqual(body["profile"]["name"], self.user.name) | ||||
| @ -185,7 +185,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | ||||
|         self.container = self.setup_client() | ||||
|  | ||||
|         self.driver.get("http://localhost:9009/implicit/") | ||||
|         sleep(2) | ||||
|         self.wait.until(ec.title_contains("authentik")) | ||||
|         self.login() | ||||
|  | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor"))) | ||||
| @ -203,7 +203,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | ||||
|         ).click() | ||||
|  | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) | ||||
|         sleep(1) | ||||
|         self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|  | ||||
|         self.assertEqual(body["profile"]["nickname"], self.user.username) | ||||
| @ -250,7 +250,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | ||||
|  | ||||
|         self.container = self.setup_client() | ||||
|         self.driver.get("http://localhost:9009/implicit/") | ||||
|         sleep(2) | ||||
|         self.wait.until(ec.title_contains("authentik")) | ||||
|         self.login() | ||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))) | ||||
|         self.assertEqual( | ||||
|  | ||||
| @ -11,6 +11,7 @@ Scopes can be configured using Scope Mappings, a type of [Property Mappings](../ | ||||
| | Authorization        | `/application/o/authorize/`                                          | | ||||
| | Token                | `/application/o/token/`                                              | | ||||
| | User Info            | `/application/o/userinfo/`                                           | | ||||
| | Token Revoke         | `/application/o/revoke/`                                             | | ||||
| | End Session          | `/application/o/<application slug>/end-session/`                     | | ||||
| | JWKS                 | `/application/o/<application slug>/jwks/`                            | | ||||
| | OpenID Configuration | `/application/o/<application slug>/.well-known/openid-configuration` | | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L