providers/oauth2: token revoke (#3077)
This commit is contained in:
parent
24a21c1761
commit
8dbb0bd2c6
|
@ -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, "
|
"The access token provided is expired, revoked, malformed, "
|
||||||
"or invalid for other reasons",
|
"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]
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
|
@ -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,
|
||||||
|
|
|
@ -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