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
|
return
|
||||||
# Since we can't directly pass other variables to signals, and we want to log the method
|
# 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
|
# 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] = method
|
||||||
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
|
||||||
request.session[SESSION_KEY_PLAN] = flow_plan
|
request.session[SESSION_KEY_PLAN] = flow_plan
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||||
|
|
||||||
PROMPT_NONE = "none"
|
PROMPT_NONE = "none"
|
||||||
PROMPT_CONSNET = "consent"
|
PROMPT_CONSNET = "consent"
|
||||||
PROMPT_LOGIN = "login"
|
PROMPT_LOGIN = "login"
|
||||||
|
|
||||||
SCOPE_OPENID = "openid"
|
SCOPE_OPENID = "openid"
|
||||||
SCOPE_OPENID_PROFILE = "profile"
|
SCOPE_OPENID_PROFILE = "profile"
|
||||||
SCOPE_OPENID_EMAIL = "email"
|
SCOPE_OPENID_EMAIL = "email"
|
||||||
|
|
|
@ -168,7 +168,7 @@ class TokenError(OAuth2Error):
|
||||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_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": "Client authentication failed (e.g., unknown client, "
|
||||||
"no client authentication included, or unsupported "
|
"no client authentication included, or unsupported "
|
||||||
|
@ -188,7 +188,7 @@ class TokenError(OAuth2Error):
|
||||||
def __init__(self, error):
|
def __init__(self, error):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.error = error
|
self.error = error
|
||||||
self.description = self._errors[error]
|
self.description = self.errors[error]
|
||||||
|
|
||||||
|
|
||||||
class BearerTokenError(OAuth2Error):
|
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 (
|
from authentik.providers.oauth2.constants import (
|
||||||
ACR_AUTHENTIK_DEFAULT,
|
ACR_AUTHENTIK_DEFAULT,
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
SCOPE_OPENID,
|
SCOPE_OPENID,
|
||||||
)
|
)
|
||||||
|
@ -78,6 +79,7 @@ class ProviderInfoView(View):
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
GrantTypes.IMPLICIT,
|
GrantTypes.IMPLICIT,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
],
|
],
|
||||||
"id_token_signing_alg_values_supported": [supported_alg],
|
"id_token_signing_alg_values_supported": [supported_alg],
|
||||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
# 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 django.views import View
|
||||||
from structlog.stdlib import get_logger
|
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.events.models import Event, EventAction
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
)
|
)
|
||||||
from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
||||||
|
@ -42,6 +45,7 @@ class TokenParams:
|
||||||
|
|
||||||
authorization_code: Optional[AuthorizationCode] = None
|
authorization_code: Optional[AuthorizationCode] = None
|
||||||
refresh_token: Optional[RefreshToken] = None
|
refresh_token: Optional[RefreshToken] = None
|
||||||
|
user: Optional[User] = None
|
||||||
|
|
||||||
code_verifier: Optional[str] = None
|
code_verifier: Optional[str] = None
|
||||||
|
|
||||||
|
@ -75,50 +79,23 @@ class TokenParams:
|
||||||
)
|
)
|
||||||
|
|
||||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL:
|
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||||
if self.provider.client_secret != self.client_secret:
|
if (
|
||||||
|
self.provider.client_type == ClientTypes.CONFIDENTIAL
|
||||||
|
and self.provider.client_secret != self.client_secret
|
||||||
|
):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid client secret: client does not have secret",
|
"Invalid client secret",
|
||||||
client_id=self.provider.client_id,
|
client_id=self.provider.client_id,
|
||||||
secret=self.provider.client_secret,
|
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
self.__post_init_code(raw_code)
|
self.__post_init_code(raw_code)
|
||||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
if not raw_token:
|
self.__post_init_refresh(raw_token, request)
|
||||||
LOGGER.warning("Missing refresh token")
|
elif self.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||||
raise TokenError("invalid_grant")
|
self.__post_init_client_credentials(request)
|
||||||
|
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
raise TokenError("unsupported_grant_type")
|
raise TokenError("unsupported_grant_type")
|
||||||
|
@ -175,6 +152,77 @@ class TokenParams:
|
||||||
LOGGER.warning("Code challenge not matching")
|
LOGGER.warning("Code challenge not matching")
|
||||||
raise TokenError("invalid_grant")
|
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):
|
class TokenView(View):
|
||||||
"""Generate tokens for clients"""
|
"""Generate tokens for clients"""
|
||||||
|
@ -208,11 +256,14 @@ class TokenView(View):
|
||||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||||
|
|
||||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
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())
|
return TokenResponse(self.create_code_response())
|
||||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
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())
|
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}")
|
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||||
except TokenError as error:
|
except TokenError as error:
|
||||||
return TokenResponse(error.create_dict(), status=400)
|
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()),
|
"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` |
|
| 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.
|
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