diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 18decdb5e5c..c1279b0085a 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -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 @@ def __init__( ): 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 @@ def __init__(self, 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): """ 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] diff --git a/authentik/providers/oauth2/tests/test_introspect.py b/authentik/providers/oauth2/tests/test_introspect.py new file mode 100644 index 00000000000..40dea7bc521 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_introspect.py @@ -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, + }, + ) diff --git a/authentik/providers/oauth2/tests/test_revoke.py b/authentik/providers/oauth2/tests/test_revoke.py new file mode 100644 index 00000000000..0e474d8dca5 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_revoke.py @@ -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) diff --git a/authentik/providers/oauth2/tests/test_userinfo.py b/authentik/providers/oauth2/tests/test_userinfo.py index 25756a8368c..e4e756d79ca 100644 --- a/authentik/providers/oauth2/tests/test_userinfo.py +++ b/authentik/providers/oauth2/tests/test_userinfo.py @@ -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(), diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py index 33a88bea08a..075fc64d500 100644 --- a/authentik/providers/oauth2/urls.py +++ b/authentik/providers/oauth2/urls.py @@ -10,6 +10,7 @@ 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 @@ csrf_exempt(TokenIntrospectionView.as_view()), name="token-introspection", ), + path( + "revoke/", + csrf_exempt(TokenRevokeView.as_view()), + name="token-revoke", + ), path( "/end-session/", RedirectView.as_view(pattern_name="authentik_core:if-session-end"), diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py index 895f3eb4161..24d77175347 100644 --- a/authentik/providers/oauth2/utils.py +++ b/authentik/providers/oauth2/utils.py @@ -12,7 +12,7 @@ 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 view_wrapper(request: HttpRequest, *args, **kwargs): 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""" diff --git a/authentik/providers/oauth2/views/introspection.py b/authentik/providers/oauth2/views/introspection.py index bdf0ae7a57d..a84fe8fc70a 100644 --- a/authentik/providers/oauth2/views/introspection.py +++ b/authentik/providers/oauth2/views/introspection.py @@ -7,11 +7,7 @@ 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 @@ def __post_init__(self): 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 @@ def __post_init__(self): ) 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 @@ def from_request(request: HttpRequest) -> "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): diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index 49129d182df..f1356c3b5c6 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -58,6 +58,9 @@ def get_info(self, provider: OAuth2Provider) -> dict[str, Any]: "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, diff --git a/authentik/providers/oauth2/views/token_revoke.py b/authentik/providers/oauth2/views/token_revoke.py new file mode 100644 index 00000000000..6b83caeaeb6 --- /dev/null +++ b/authentik/providers/oauth2/views/token_revoke.py @@ -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) diff --git a/tests/e2e/test_provider_oauth2_oidc.py b/tests/e2e/test_provider_oauth2_oidc.py index 6c4055422fd..06a44ce8f0c 100644 --- a/tests/e2e/test_provider_oauth2_oidc.py +++ b/tests/e2e/test_provider_oauth2_oidc.py @@ -142,6 +142,7 @@ def test_authorization_consent_implied(self): 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 @@ def test_authorization_consent_explicit(self): ).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) diff --git a/tests/e2e/test_provider_oauth2_oidc_implicit.py b/tests/e2e/test_provider_oauth2_oidc_implicit.py index 8734f01c0d3..fa64165ea41 100644 --- a/tests/e2e/test_provider_oauth2_oidc_implicit.py +++ b/tests/e2e/test_provider_oauth2_oidc_implicit.py @@ -140,10 +140,10 @@ def test_authorization_consent_implied(self): 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 @@ def test_authorization_consent_explicit(self): 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 @@ def test_authorization_consent_explicit(self): ).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 @@ def test_authorization_denied(self): 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( diff --git a/website/docs/providers/oauth2/index.md b/website/docs/providers/oauth2/index.md index 33a0cee19ff..dc45fcd04c2 100644 --- a/website/docs/providers/oauth2/index.md +++ b/website/docs/providers/oauth2/index.md @@ -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//end-session/` | | JWKS | `/application/o//jwks/` | | OpenID Configuration | `/application/o//.well-known/openid-configuration` |