providers/oauth2: token revoke (#3077)
This commit is contained in:
		| @ -95,38 +95,45 @@ class TokenIntrospectionError(OAuth2Error): | |||||||
| class AuthorizeError(OAuth2Error): | class AuthorizeError(OAuth2Error): | ||||||
|     """General Authorization Errors""" |     """General Authorization Errors""" | ||||||
|  |  | ||||||
|     _errors = { |     errors = { | ||||||
|         # OAuth2 errors. |         # OAuth2 errors. | ||||||
|         # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 |         # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 | ||||||
|         "invalid_request": "The request is otherwise malformed", |         "invalid_request": "The request is otherwise malformed", | ||||||
|         "unauthorized_client": "The client is not authorized to request an " |         "unauthorized_client": ( | ||||||
|         "authorization code using this method", |             "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 " |         "access_denied": "The resource owner or authorization server denied the request", | ||||||
|         "support obtaining an authorization code " |         "unsupported_response_type": ( | ||||||
|         "using this method", |             "The authorization server does not support obtaining an authorization code " | ||||||
|         "invalid_scope": "The requested scope is invalid, unknown, or " "malformed", |             "using this method" | ||||||
|  |         ), | ||||||
|  |         "invalid_scope": "The requested scope is invalid, unknown, or malformed", | ||||||
|         "server_error": "The authorization server encountered an error", |         "server_error": "The authorization server encountered an error", | ||||||
|         "temporarily_unavailable": "The authorization server is currently " |         "temporarily_unavailable": ( | ||||||
|         "unable to handle the request due to a " |             "The authorization server is currently unable to handle the request due to a " | ||||||
|         "temporary overloading or maintenance of " |             "temporary overloading or maintenance of the server" | ||||||
|         "the server", |         ), | ||||||
|         # OpenID errors. |         # OpenID errors. | ||||||
|         # http://openid.net/specs/openid-connect-core-1_0.html#AuthError |         # http://openid.net/specs/openid-connect-core-1_0.html#AuthError | ||||||
|         "interaction_required": "The Authorization Server requires End-User " |         "interaction_required": ( | ||||||
|         "interaction of some form to proceed", |             "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 " |         "login_required": "The Authorization Server requires End-User authentication", | ||||||
|         "session at the Authorization Server", |         "account_selection_required": ( | ||||||
|         "consent_required": "The Authorization Server requires End-User" "consent", |             "The End-User is required to select a session at the Authorization Server" | ||||||
|         "invalid_request_uri": "The request_uri in the Authorization Request " |         ), | ||||||
|         "returns an error or contains invalid data", |         "consent_required": "The Authorization Server requires End-Userconsent", | ||||||
|         "invalid_request_object": "The request parameter contains an invalid " "Request Object", |         "invalid_request_uri": ( | ||||||
|         "request_not_supported": "The provider does not support use of the " "request parameter", |             "The request_uri in the Authorization Request returns an error or contains invalid data" | ||||||
|         "request_uri_not_supported": "The provider does not support use of the " |         ), | ||||||
|         "request_uri parameter", |         "invalid_request_object": "The request parameter contains an invalid Request Object", | ||||||
|         "registration_not_supported": "The provider does not support use of " |         "request_not_supported": "The provider does not support use of the request parameter", | ||||||
|         "the registration 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__( |     def __init__( | ||||||
| @ -138,7 +145,7 @@ class AuthorizeError(OAuth2Error): | |||||||
|     ): |     ): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.error = error |         self.error = error | ||||||
|         self.description = self._errors[error] |         self.description = self.errors[error] | ||||||
|         self.redirect_uri = redirect_uri |         self.redirect_uri = redirect_uri | ||||||
|         self.grant_type = grant_type |         self.grant_type = grant_type | ||||||
|         self.state = state |         self.state = state | ||||||
| @ -170,19 +177,25 @@ class TokenError(OAuth2Error): | |||||||
|  |  | ||||||
|     errors = { |     errors = { | ||||||
|         "invalid_request": "The request is otherwise malformed", |         "invalid_request": "The request is otherwise malformed", | ||||||
|         "invalid_client": "Client authentication failed (e.g., unknown client, " |         "invalid_client": ( | ||||||
|         "no client authentication included, or unsupported " |             "Client authentication failed (e.g., unknown client, no client authentication " | ||||||
|         "authentication method)", |             "included, or unsupported authentication method)" | ||||||
|         "invalid_grant": "The provided authorization grant or refresh token is " |         ), | ||||||
|         "invalid, expired, revoked, does not match the " |         "invalid_grant": ( | ||||||
|         "redirection URI used in the authorization request, " |             "The provided authorization grant or refresh token is invalid, expired, revoked, " | ||||||
|         "or was issued to another client", |             "does not match the redirection URI used in the authorization request, " | ||||||
|         "unauthorized_client": "The authenticated client is not authorized to " |             "or was issued to another client" | ||||||
|         "use this authorization grant type", |         ), | ||||||
|         "unsupported_grant_type": "The authorization grant type is not " |         "unauthorized_client": ( | ||||||
|         "supported by the authorization server", |             "The authenticated client is not authorized to use this authorization grant type" | ||||||
|         "invalid_scope": "The requested scope is invalid, unknown, malformed, " |         ), | ||||||
|         "or exceeds the scope granted by the resource owner", |         "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): |     def __init__(self, error): | ||||||
| @ -191,17 +204,39 @@ class TokenError(OAuth2Error): | |||||||
|         self.description = self.errors[error] |         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): | class BearerTokenError(OAuth2Error): | ||||||
|     """ |     """ | ||||||
|     OAuth2 errors. |     OAuth2 errors. | ||||||
|     https://tools.ietf.org/html/rfc6750#section-3.1 |     https://tools.ietf.org/html/rfc6750#section-3.1 | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     _errors = { |     errors = { | ||||||
|         "invalid_request": ("The request is otherwise malformed", 400), |         "invalid_request": ("The request is otherwise malformed", 400), | ||||||
|         "invalid_token": ( |         "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, |             401, | ||||||
|         ), |         ), | ||||||
|         "insufficient_scope": ( |         "insufficient_scope": ( | ||||||
| @ -213,6 +248,6 @@ class BearerTokenError(OAuth2Error): | |||||||
|     def __init__(self, code): |     def __init__(self, code): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.code = code |         self.code = code | ||||||
|         error_tuple = self._errors.get(code, ("", "")) |         error_tuple = self.errors.get(code, ("", "")) | ||||||
|         self.description = error_tuple[0] |         self.description = error_tuple[0] | ||||||
|         self.status = error_tuple[1] |         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: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         ObjectManager().run() |         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( |         self.provider: OAuth2Provider = OAuth2Provider.objects.create( | ||||||
|             name="test", |             name=generate_id(), | ||||||
|             client_id=generate_id(), |             client_id=generate_id(), | ||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             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.jwks import JWKSView | ||||||
| from authentik.providers.oauth2.views.provider import ProviderInfoView | from authentik.providers.oauth2.views.provider import ProviderInfoView | ||||||
| from authentik.providers.oauth2.views.token import TokenView | 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 | from authentik.providers.oauth2.views.userinfo import UserInfoView | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
| @ -29,6 +30,11 @@ urlpatterns = [ | |||||||
|         csrf_exempt(TokenIntrospectionView.as_view()), |         csrf_exempt(TokenIntrospectionView.as_view()), | ||||||
|         name="token-introspection", |         name="token-introspection", | ||||||
|     ), |     ), | ||||||
|  |     path( | ||||||
|  |         "revoke/", | ||||||
|  |         csrf_exempt(TokenRevokeView.as_view()), | ||||||
|  |         name="token-revoke", | ||||||
|  |     ), | ||||||
|     path( |     path( | ||||||
|         "<slug:application_slug>/end-session/", |         "<slug:application_slug>/end-session/", | ||||||
|         RedirectView.as_view(pattern_name="authentik_core:if-session-end"), |         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.events.models import Event, EventAction | ||||||
| from authentik.providers.oauth2.errors import BearerTokenError | 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() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -172,6 +172,20 @@ def protected_resource_view(scopes: list[str]): | |||||||
|     return wrapper |     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): | class HttpResponseRedirectScheme(HttpResponseRedirect): | ||||||
|     """HTTP Response to redirect, can be to a non-http scheme""" |     """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.errors import TokenIntrospectionError | ||||||
| from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken | from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken | ||||||
| from authentik.providers.oauth2.utils import ( | from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider | ||||||
|     TokenResponse, |  | ||||||
|     extract_access_token, |  | ||||||
|     extract_client_auth, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -21,8 +17,8 @@ class TokenIntrospectionParams: | |||||||
|     """Parameters for Token Introspection""" |     """Parameters for Token Introspection""" | ||||||
|  |  | ||||||
|     token: RefreshToken |     token: RefreshToken | ||||||
|  |     provider: OAuth2Provider | ||||||
|  |  | ||||||
|     provider: OAuth2Provider = field(init=False) |  | ||||||
|     id_token: IDToken = field(init=False) |     id_token: IDToken = field(init=False) | ||||||
|  |  | ||||||
|     def __post_init__(self): |     def __post_init__(self): | ||||||
| @ -30,7 +26,6 @@ class TokenIntrospectionParams: | |||||||
|             LOGGER.debug("Token is not valid") |             LOGGER.debug("Token is not valid") | ||||||
|             raise TokenIntrospectionError() |             raise TokenIntrospectionError() | ||||||
|  |  | ||||||
|         self.provider = self.token.provider |  | ||||||
|         self.id_token = self.token.id_token |         self.id_token = self.token.id_token | ||||||
|  |  | ||||||
|         if not self.token.id_token: |         if not self.token.id_token: | ||||||
| @ -40,30 +35,6 @@ class TokenIntrospectionParams: | |||||||
|             ) |             ) | ||||||
|             raise TokenIntrospectionError() |             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 |     @staticmethod | ||||||
|     def from_request(request: HttpRequest) -> "TokenIntrospectionParams": |     def from_request(request: HttpRequest) -> "TokenIntrospectionParams": | ||||||
|         """Extract required Parameters from HTTP Request""" |         """Extract required Parameters from HTTP Request""" | ||||||
| @ -75,19 +46,17 @@ class TokenIntrospectionParams: | |||||||
|             LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) |             LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) | ||||||
|             raise TokenIntrospectionError() |             raise TokenIntrospectionError() | ||||||
|  |  | ||||||
|  |         provider = authenticate_provider(request) | ||||||
|  |         if not provider: | ||||||
|  |             raise TokenIntrospectionError | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             token: RefreshToken = RefreshToken.objects.select_related("provider").get( |             token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter) | ||||||
|                 **token_filter |  | ||||||
|             ) |  | ||||||
|         except RefreshToken.DoesNotExist: |         except RefreshToken.DoesNotExist: | ||||||
|             LOGGER.debug("Token does not exist", token=raw_token) |             LOGGER.debug("Token does not exist", token=raw_token) | ||||||
|             raise TokenIntrospectionError() |             raise TokenIntrospectionError() | ||||||
|  |  | ||||||
|         params = TokenIntrospectionParams(token=token) |         return TokenIntrospectionParams(token=token, provider=provider) | ||||||
|         if not any([params.authenticate_basic(request), params.authenticate_bearer(request)]): |  | ||||||
|             LOGGER.warning("Not authenticated") |  | ||||||
|             raise TokenIntrospectionError() |  | ||||||
|         return params |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenIntrospectionView(View): | class TokenIntrospectionView(View): | ||||||
|  | |||||||
| @ -58,6 +58,9 @@ class ProviderInfoView(View): | |||||||
|             "introspection_endpoint": self.request.build_absolute_uri( |             "introspection_endpoint": self.request.build_absolute_uri( | ||||||
|                 reverse("authentik_providers_oauth2:token-introspection") |                 reverse("authentik_providers_oauth2:token-introspection") | ||||||
|             ), |             ), | ||||||
|  |             "revocation_endpoint": self.request.build_absolute_uri( | ||||||
|  |                 reverse("authentik_providers_oauth2:token-revoke") | ||||||
|  |             ), | ||||||
|             "response_types_supported": [ |             "response_types_supported": [ | ||||||
|                 ResponseTypes.CODE, |                 ResponseTypes.CODE, | ||||||
|                 ResponseTypes.ID_TOKEN, |                 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.driver.get("http://localhost:9009") | ||||||
|         self.login() |         self.login() | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) |         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) |         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||||
|  |  | ||||||
|         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) |         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) | ||||||
| @ -206,6 +207,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | |||||||
|         ).click() |         ).click() | ||||||
|  |  | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) |         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) |         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||||
|  |  | ||||||
|         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) |         self.assertEqual(body["IDTokenClaims"]["nickname"], self.user.username) | ||||||
|  | |||||||
| @ -140,10 +140,10 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | |||||||
|         self.container = self.setup_client() |         self.container = self.setup_client() | ||||||
|  |  | ||||||
|         self.driver.get("http://localhost:9009/implicit/") |         self.driver.get("http://localhost:9009/implicit/") | ||||||
|         sleep(2) |         self.wait.until(ec.title_contains("authentik")) | ||||||
|         self.login() |         self.login() | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) |         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) |         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||||
|         self.assertEqual(body["profile"]["nickname"], self.user.username) |         self.assertEqual(body["profile"]["nickname"], self.user.username) | ||||||
|         self.assertEqual(body["profile"]["name"], self.user.name) |         self.assertEqual(body["profile"]["name"], self.user.name) | ||||||
| @ -185,7 +185,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | |||||||
|         self.container = self.setup_client() |         self.container = self.setup_client() | ||||||
|  |  | ||||||
|         self.driver.get("http://localhost:9009/implicit/") |         self.driver.get("http://localhost:9009/implicit/") | ||||||
|         sleep(2) |         self.wait.until(ec.title_contains("authentik")) | ||||||
|         self.login() |         self.login() | ||||||
|  |  | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor"))) |         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor"))) | ||||||
| @ -203,7 +203,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | |||||||
|         ).click() |         ).click() | ||||||
|  |  | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) |         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) |         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||||
|  |  | ||||||
|         self.assertEqual(body["profile"]["nickname"], self.user.username) |         self.assertEqual(body["profile"]["nickname"], self.user.username) | ||||||
| @ -250,7 +250,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | |||||||
|  |  | ||||||
|         self.container = self.setup_client() |         self.container = self.setup_client() | ||||||
|         self.driver.get("http://localhost:9009/implicit/") |         self.driver.get("http://localhost:9009/implicit/") | ||||||
|         sleep(2) |         self.wait.until(ec.title_contains("authentik")) | ||||||
|         self.login() |         self.login() | ||||||
|         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))) |         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ Scopes can be configured using Scope Mappings, a type of [Property Mappings](../ | |||||||
| | Authorization        | `/application/o/authorize/`                                          | | | Authorization        | `/application/o/authorize/`                                          | | ||||||
| | Token                | `/application/o/token/`                                              | | | Token                | `/application/o/token/`                                              | | ||||||
| | User Info            | `/application/o/userinfo/`                                           | | | User Info            | `/application/o/userinfo/`                                           | | ||||||
|  | | Token Revoke         | `/application/o/revoke/`                                             | | ||||||
| | End Session          | `/application/o/<application slug>/end-session/`                     | | | End Session          | `/application/o/<application slug>/end-session/`                     | | ||||||
| | JWKS                 | `/application/o/<application slug>/jwks/`                            | | | JWKS                 | `/application/o/<application slug>/jwks/`                            | | ||||||
| | OpenID Configuration | `/application/o/<application slug>/.well-known/openid-configuration` | | | OpenID Configuration | `/application/o/<application slug>/.well-known/openid-configuration` | | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L