diff --git a/authentik/core/auth.py b/authentik/core/auth.py index 86cda15fc..45c688b15 100644 --- a/authentik/core/auth.py +++ b/authentik/core/auth.py @@ -30,7 +30,7 @@ class InbuiltBackend(ModelBackend): return # Since we can't directly pass other variables to signals, and we want to log the method # and the token used, we assume we're running in a flow and set a variable in the context - flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] + flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan("")) flow_plan.context[PLAN_CONTEXT_METHOD] = method flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs)) request.session[SESSION_KEY_PLAN] = flow_plan diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index a6b3dd962..be1b96038 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -2,9 +2,12 @@ GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec +GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" + PROMPT_NONE = "none" PROMPT_CONSNET = "consent" PROMPT_LOGIN = "login" + SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_EMAIL = "email" diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 3ffd88262..ad2bfe9fa 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -168,7 +168,7 @@ class TokenError(OAuth2Error): https://tools.ietf.org/html/rfc6749#section-5.2 """ - _errors = { + errors = { "invalid_request": "The request is otherwise malformed", "invalid_client": "Client authentication failed (e.g., unknown client, " "no client authentication included, or unsupported " @@ -188,7 +188,7 @@ class TokenError(OAuth2Error): def __init__(self, error): super().__init__() self.error = error - self.description = self._errors[error] + self.description = self.errors[error] class BearerTokenError(OAuth2Error): diff --git a/authentik/providers/oauth2/tests/test_token_client_credentials.py b/authentik/providers/oauth2/tests/test_token_client_credentials.py new file mode 100644 index 000000000..66e5428b1 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_token_client_credentials.py @@ -0,0 +1,174 @@ +"""Test token view""" +from json import loads + +from django.test import RequestFactory +from django.urls import reverse +from jwt import decode + +from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents +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.managed.manager import ObjectManager +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.constants import ( + GRANT_TYPE_CLIENT_CREDENTIALS, + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, +) +from authentik.providers.oauth2.errors import TokenError +from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping +from authentik.providers.oauth2.tests.utils import OAuthTestCase + + +class TestTokenClientCredentials(OAuthTestCase): + """Test token (client_credentials) view""" + + def setUp(self) -> None: + super().setUp() + ObjectManager().run() + self.factory = RequestFactory() + self.provider = OAuth2Provider.objects.create( + name="test", + client_id=generate_id(), + client_secret=generate_key(), + authorization_flow=create_test_flow(), + redirect_uris="http://testserver", + signing_key=create_test_cert(), + ) + self.provider.property_mappings.set(ScopeMapping.objects.all()) + self.app = Application.objects.create(name="test", slug="test", provider=self.provider) + self.user = create_test_admin_user("sa") + self.user.attributes[USER_ATTRIBUTE_SA] = True + self.user.save() + self.token = Token.objects.create( + identifier="sa-token", + user=self.user, + intent=TokenIntents.INTENT_APP_PASSWORD, + expiring=False, + ) + + def test_wrong_user(self): + """test invalid username""" + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": SCOPE_OPENID, + "client_id": self.provider.client_id, + "username": "saa", + "password": self.token.key, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content.decode(), + {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, + ) + + def test_wrong_token(self): + """test invalid token""" + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": SCOPE_OPENID, + "client_id": self.provider.client_id, + "username": "sa", + "password": self.token.key + "foo", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content.decode(), + {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, + ) + + def test_non_sa(self): + """test non service-account""" + self.user.attributes[USER_ATTRIBUTE_SA] = False + self.user.save() + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": SCOPE_OPENID, + "client_id": self.provider.client_id, + "username": "sa", + "password": self.token.key, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content.decode(), + {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, + ) + + def test_no_provider(self): + """test no provider""" + self.app.provider = None + self.app.save() + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": SCOPE_OPENID, + "client_id": self.provider.client_id, + "username": "sa", + "password": self.token.key, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content.decode(), + {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, + ) + + def test_permission_denied(self): + """test permission denied""" + group = Group.objects.create(name="foo") + PolicyBinding.objects.create( + group=group, + target=self.app, + order=0, + ) + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": SCOPE_OPENID, + "client_id": self.provider.client_id, + "username": "sa", + "password": self.token.key, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content.decode(), + {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, + ) + + def test_successful(self): + """test successful""" + response = self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, + "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", + "client_id": self.provider.client_id, + "username": "sa", + "password": self.token.key, + }, + ) + self.assertEqual(response.status_code, 200) + body = loads(response.content.decode()) + self.assertEqual(body["token_type"], "bearer") + _, alg = self.provider.get_jwt_key() + jwt = decode( + body["access_token"], + key=self.provider.signing_key.public_key, + algorithms=[alg], + audience=self.provider.client_id, + ) + self.assertEqual(jwt["given_name"], self.user.name) + self.assertEqual(jwt["preferred_username"], self.user.username) diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index b298706f5..6bb4ee20b 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -10,6 +10,7 @@ from authentik.core.models import Application from authentik.providers.oauth2.constants import ( ACR_AUTHENTIK_DEFAULT, GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_REFRESH_TOKEN, SCOPE_OPENID, ) @@ -78,6 +79,7 @@ class ProviderInfoView(View): GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN, GrantTypes.IMPLICIT, + GRANT_TYPE_CLIENT_CREDENTIALS, ], "id_token_signing_alg_values_supported": [supported_alg], # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index b59aa6071..96925dbfc 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -8,10 +8,13 @@ from django.http import HttpRequest, HttpResponse from django.views import View from structlog.stdlib import get_logger +from authentik.core.models import USER_ATTRIBUTE_SA, Application, Token, TokenIntents, User from authentik.events.models import Event, EventAction from authentik.lib.utils.time import timedelta_from_string +from authentik.policies.engine import PolicyEngine from authentik.providers.oauth2.constants import ( GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_REFRESH_TOKEN, ) from authentik.providers.oauth2.errors import TokenError, UserAuthError @@ -42,6 +45,7 @@ class TokenParams: authorization_code: Optional[AuthorizationCode] = None refresh_token: Optional[RefreshToken] = None + user: Optional[User] = None code_verifier: Optional[str] = None @@ -75,50 +79,23 @@ class TokenParams: ) def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest): - if self.provider.client_type == ClientTypes.CONFIDENTIAL: - if self.provider.client_secret != self.client_secret: + if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]: + if ( + self.provider.client_type == ClientTypes.CONFIDENTIAL + and self.provider.client_secret != self.client_secret + ): LOGGER.warning( - "Invalid client secret: client does not have secret", + "Invalid client secret", client_id=self.provider.client_id, - secret=self.provider.client_secret, ) raise TokenError("invalid_client") if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: self.__post_init_code(raw_code) elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: - if not raw_token: - LOGGER.warning("Missing refresh token") - raise TokenError("invalid_grant") - - try: - self.refresh_token = RefreshToken.objects.get( - refresh_token=raw_token, provider=self.provider - ) - if self.refresh_token.is_expired: - LOGGER.warning( - "Refresh token is expired", - token=raw_token, - ) - raise TokenError("invalid_grant") - # https://tools.ietf.org/html/rfc6749#section-6 - # Fallback to original token's scopes when none are given - if not self.scope: - self.scope = self.refresh_token.scope - except RefreshToken.DoesNotExist: - LOGGER.warning( - "Refresh token does not exist", - token=raw_token, - ) - raise TokenError("invalid_grant") - if self.refresh_token.revoked: - LOGGER.warning("Refresh token is revoked", token=raw_token) - Event.new( - action=EventAction.SUSPICIOUS_REQUEST, - message="Revoked refresh token was used", - token=raw_token, - ).from_http(request) - raise TokenError("invalid_grant") + self.__post_init_refresh(raw_token, request) + elif self.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS: + self.__post_init_client_credentials(request) else: LOGGER.warning("Invalid grant type", grant_type=self.grant_type) raise TokenError("unsupported_grant_type") @@ -175,6 +152,77 @@ class TokenParams: LOGGER.warning("Code challenge not matching") raise TokenError("invalid_grant") + def __post_init_refresh(self, raw_token: str, request: HttpRequest): + if not raw_token: + LOGGER.warning("Missing refresh token") + raise TokenError("invalid_grant") + + try: + self.refresh_token = RefreshToken.objects.get( + refresh_token=raw_token, provider=self.provider + ) + if self.refresh_token.is_expired: + LOGGER.warning( + "Refresh token is expired", + token=raw_token, + ) + raise TokenError("invalid_grant") + # https://tools.ietf.org/html/rfc6749#section-6 + # Fallback to original token's scopes when none are given + if not self.scope: + self.scope = self.refresh_token.scope + except RefreshToken.DoesNotExist: + LOGGER.warning( + "Refresh token does not exist", + token=raw_token, + ) + raise TokenError("invalid_grant") + if self.refresh_token.revoked: + LOGGER.warning("Refresh token is revoked", token=raw_token) + Event.new( + action=EventAction.SUSPICIOUS_REQUEST, + message="Revoked refresh token was used", + token=raw_token, + ).from_http(request) + raise TokenError("invalid_grant") + + def __post_init_client_credentials(self, request: HttpRequest): + # Authenticate user based on credentials + username = request.POST.get("username") + password = request.POST.get("password") + user = User.objects.filter(username=username).first() + if not user: + raise TokenError("invalid_grant") + token: Token = Token.filter_not_expired( + key=password, intent=TokenIntents.INTENT_APP_PASSWORD + ).first() + if not token or token.user.uid != user.uid: + raise TokenError("invalid_grant") + self.user = user + if not self.user.attributes.get(USER_ATTRIBUTE_SA, False): + # Non-service accounts are not allowed + LOGGER.info("Non-service-account tried to use client credentials", user=self.user) + raise TokenError("invalid_grant") + + Event.new( + action=EventAction.LOGIN, + PLAN_CONTEXT_METHOD="token", + PLAN_CONTEXT_METHOD_ARGS={ + "identifier": token.identifier, + }, + ).from_http(request, user=user) + + # Authorize user access + app = Application.objects.filter(provider=self.provider).first() + if not app or not app.provider: + raise TokenError("invalid_grant") + engine = PolicyEngine(app, self.user, request) + engine.build() + result = engine.result + if not result.passing: + LOGGER.info("User not authenticated for application", user=self.user, app=app) + raise TokenError("invalid_grant") + class TokenView(View): """Generate tokens for clients""" @@ -208,11 +256,14 @@ class TokenView(View): self.params = TokenParams.parse(request, self.provider, client_id, client_secret) if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: - LOGGER.info("Converting authorization code to refresh token") + LOGGER.debug("Converting authorization code to refresh token") return TokenResponse(self.create_code_response()) if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: - LOGGER.info("Refreshing refresh token") + LOGGER.debug("Refreshing refresh token") return TokenResponse(self.create_refresh_response()) + if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS: + LOGGER.debug("Client credentials grant") + return TokenResponse(self.create_client_credentials_response()) raise ValueError(f"Invalid grant_type: {self.params.grant_type}") except TokenError as error: return TokenResponse(error.create_dict(), status=400) @@ -292,3 +343,30 @@ class TokenView(View): ), "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), } + + def create_client_credentials_response(self) -> dict[str, Any]: + """See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4""" + provider: OAuth2Provider = self.params.provider + + refresh_token: RefreshToken = provider.create_refresh_token( + user=self.params.user, + scope=self.params.scope, + request=self.request, + ) + refresh_token.id_token = refresh_token.create_id_token( + user=self.params.user, + request=self.request, + ) + refresh_token.id_token.at_hash = refresh_token.at_hash + + # Store the refresh_token. + refresh_token.save() + + return { + "access_token": refresh_token.access_token, + "token_type": "bearer", + "expires_in": int( + timedelta_from_string(refresh_token.provider.token_validity).total_seconds() + ), + "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), + } diff --git a/website/docs/providers/oauth2.md b/website/docs/providers/oauth2.md index 465669acf..15c537d07 100644 --- a/website/docs/providers/oauth2.md +++ b/website/docs/providers/oauth2.md @@ -29,3 +29,31 @@ To use any of the GitHub Compatibility scopes, you have to use the GitHub Compat | User Teams Info | `/user/teams` | To access the user's email address, a scope of `user:email` is required. To access their groups, `read:org` is required. Because these scopes are handled by a different endpoint, they are not customisable as a Scope Mapping. + +## Grant types + +### `authorization_code`: + +This grant is used to convert an authorization code to a refresh token. The authorization code is retrieved through the Authorization flow, and can only be used once, and expires quickly. + +### `refresh_token`: + +Refresh tokens can be used as long-lived tokens to access user data, and further renew the refresh token down the road. + +### `client_credentials`: + +Client credentials can be used for machine-to-machine communication authentication. Clients can authenticate themselves using service-accounts; standard client_id + client_secret is not sufficient. This behavior is due to providers only being able to have a single secret at any given time. + +Hence identification is based on service-accounts, and authentication is based on App-password tokens. These objects can be created in a single step using the *Create Service account* function. + +An example request can look like this: + +``` +POST /application/o/token/ HTTP/1.1 +Host: authentik.company +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&username=my-service-account&password=my-token +``` + +This will return a JSON response with an `access_token`, which is a signed JWT token. This token can be sent along requests to other hosts, which can then validate the JWT based on the signing key configured in authentik.