providers/oauth2: initial client_credentials grant support (#2437)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
680d4fc20d
commit
920d1f1b0e
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
Reference in New Issue