providers/oauth2: add device flow (#3334)
* start device flow Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: fix inconsistent app filtering Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add tenant device code flow Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add throttling to device code view Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * somewhat unrelated changes Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add initial device code entry flow Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add finish stage Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * it works Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add support for verification_uri_complete Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add some tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add more tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add docs Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		| @ -43,7 +43,7 @@ COPY ./internal /work/internal | ||||
| COPY ./go.mod /work/go.mod | ||||
| COPY ./go.sum /work/go.sum | ||||
|  | ||||
| RUN go build -o /work/authentik ./cmd/server/main.go | ||||
| RUN go build -o /work/authentik ./cmd/server/ | ||||
|  | ||||
| # Stage 5: Run | ||||
| FROM docker.io/python:3.10.7-slim-bullseye AS final-image | ||||
|  | ||||
| @ -50,6 +50,11 @@ email: | ||||
|   from: authentik@localhost | ||||
|   template_dir: /templates | ||||
|  | ||||
| throttle: | ||||
|   providers: | ||||
|     oauth2: | ||||
|       device: 20/hour | ||||
|  | ||||
| outposts: | ||||
|   # Placeholders: | ||||
|   # %(type)s: Outpost type; proxy, ldap, etc | ||||
|  | ||||
| @ -3,13 +3,20 @@ import string | ||||
| from random import SystemRandom | ||||
|  | ||||
|  | ||||
| def generate_id(length=40): | ||||
| def generate_code_fixed_length(length=9) -> str: | ||||
|     """Generate a numeric code""" | ||||
|     rand = SystemRandom() | ||||
|     num = rand.randrange(1, 10**length) | ||||
|     return str(num).zfill(length) | ||||
|  | ||||
|  | ||||
| def generate_id(length=40) -> str: | ||||
|     """Generate a random client ID""" | ||||
|     rand = SystemRandom() | ||||
|     return "".join(rand.choice(string.ascii_letters + string.digits) for x in range(length)) | ||||
|  | ||||
|  | ||||
| def generate_key(length=128): | ||||
| def generate_key(length=128) -> str: | ||||
|     """Generate a suitable client secret""" | ||||
|     rand = SystemRandom() | ||||
|     return "".join( | ||||
|  | ||||
| @ -9,6 +9,6 @@ class AuthentikProviderOAuth2Config(AppConfig): | ||||
|     label = "authentik_providers_oauth2" | ||||
|     verbose_name = "authentik Providers.OAuth2" | ||||
|     mountpoints = { | ||||
|         "authentik.providers.oauth2.urls_github": "", | ||||
|         "authentik.providers.oauth2.urls_root": "", | ||||
|         "authentik.providers.oauth2.urls": "application/o/", | ||||
|     } | ||||
|  | ||||
| @ -5,6 +5,7 @@ GRANT_TYPE_IMPLICIT = "implicit" | ||||
| GRANT_TYPE_REFRESH_TOKEN = "refresh_token"  # nosec | ||||
| GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" | ||||
| GRANT_TYPE_PASSWORD = "password"  # nosec | ||||
| GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" | ||||
|  | ||||
| CLIENT_ASSERTION_TYPE = "client_assertion_type" | ||||
| CLIENT_ASSERTION = "client_assertion" | ||||
|  | ||||
| @ -235,6 +235,32 @@ class TokenRevocationError(OAuth2Error): | ||||
|         self.description = self.errors[error] | ||||
|  | ||||
|  | ||||
| class DeviceCodeError(OAuth2Error): | ||||
|     """ | ||||
|     Device-code flow errors | ||||
|     See https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 | ||||
|     """ | ||||
|  | ||||
|     errors = { | ||||
|         "authorization_pending": ( | ||||
|             "The authorization request is still pending as the end user hasn't " | ||||
|             "yet completed the user-interaction steps" | ||||
|         ), | ||||
|         "access_denied": ("The authorization request was denied."), | ||||
|         "expired_token": ( | ||||
|             'The "device_code" has expired, and the device authorization ' | ||||
|             "session has concluded.  The client MAY commence a new device " | ||||
|             "authorization request but SHOULD wait for user interaction before " | ||||
|             "restarting to avoid unnecessary polling." | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|     def __init__(self, error: str): | ||||
|         super().__init__() | ||||
|         self.error = error | ||||
|         self.description = self.errors[error] | ||||
|  | ||||
|  | ||||
| class BearerTokenError(OAuth2Error): | ||||
|     """ | ||||
|     OAuth2 errors. | ||||
|  | ||||
							
								
								
									
										61
									
								
								authentik/providers/oauth2/migrations/0013_devicetoken.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								authentik/providers/oauth2/migrations/0013_devicetoken.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | ||||
| # Generated by Django 4.0.6 on 2022-07-27 08:15 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import authentik.core.models | ||||
| import authentik.lib.generators | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|         ("authentik_providers_oauth2", "0012_remove_oauth2provider_verification_keys"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="DeviceToken", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "expires", | ||||
|                     models.DateTimeField(default=authentik.core.models.default_token_duration), | ||||
|                 ), | ||||
|                 ("expiring", models.BooleanField(default=True)), | ||||
|                 ("device_code", models.TextField(default=authentik.lib.generators.generate_key)), | ||||
|                 ( | ||||
|                     "user_code", | ||||
|                     models.TextField(default=authentik.lib.generators.generate_code_fixed_length), | ||||
|                 ), | ||||
|                 ("_scope", models.TextField(default="", verbose_name="Scopes")), | ||||
|                 ( | ||||
|                     "provider", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         to="authentik_providers_oauth2.oauth2provider", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "user", | ||||
|                     models.ForeignKey( | ||||
|                         default=None, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         to=settings.AUTH_USER_MODEL, | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Device Token", | ||||
|                 "verbose_name_plural": "Device Tokens", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -23,7 +23,7 @@ from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.utils import get_user | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||
| from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config | ||||
| @ -320,8 +320,8 @@ class BaseGrantModel(models.Model): | ||||
|  | ||||
|     provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) | ||||
|     user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|     revoked = models.BooleanField(default=False) | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|  | ||||
|     @property | ||||
|     def scope(self) -> list[str]: | ||||
| @ -516,3 +516,31 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | ||||
|             token.claims = claims | ||||
|  | ||||
|         return token | ||||
|  | ||||
|  | ||||
| class DeviceToken(ExpiringModel): | ||||
|     """Device token for OAuth device flow""" | ||||
|  | ||||
|     user = models.ForeignKey( | ||||
|         "authentik_core.User", default=None, on_delete=models.CASCADE, null=True | ||||
|     ) | ||||
|     provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) | ||||
|     device_code = models.TextField(default=generate_key) | ||||
|     user_code = models.TextField(default=generate_code_fixed_length) | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|  | ||||
|     @property | ||||
|     def scope(self) -> list[str]: | ||||
|         """Return scopes as list of strings""" | ||||
|         return self._scope.split() | ||||
|  | ||||
|     @scope.setter | ||||
|     def scope(self, value): | ||||
|         self._scope = " ".join(value) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Device Token") | ||||
|         verbose_name_plural = _("Device Tokens") | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Device Token for {self.provider}" | ||||
|  | ||||
							
								
								
									
										62
									
								
								authentik/providers/oauth2/tests/test_device_backchannel.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								authentik/providers/oauth2/tests/test_device_backchannel.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| """Device backchannel tests""" | ||||
| from json import loads | ||||
|  | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.providers.oauth2.models import OAuth2Provider | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
|  | ||||
|  | ||||
| class TesOAuth2DeviceBackchannel(OAuthTestCase): | ||||
|     """Test device back channel""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.provider = OAuth2Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|             client_id="test", | ||||
|             authorization_flow=create_test_flow(), | ||||
|         ) | ||||
|         self.application = Application.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             provider=self.provider, | ||||
|         ) | ||||
|  | ||||
|     def test_backchannel_invalid(self): | ||||
|         """Test backchannel""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:device"), | ||||
|             data={ | ||||
|                 "client_id": "foo", | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:device"), | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         # test without application | ||||
|         self.application.provider = None | ||||
|         self.application.save() | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:device"), | ||||
|             data={ | ||||
|                 "client_id": "test", | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|  | ||||
|     def test_backchannel(self): | ||||
|         """Test backchannel""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:device"), | ||||
|             data={ | ||||
|                 "client_id": self.provider.client_id, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         body = loads(res.content.decode()) | ||||
|         self.assertEqual(body["expires_in"], 60) | ||||
							
								
								
									
										78
									
								
								authentik/providers/oauth2/tests/test_device_init.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								authentik/providers/oauth2/tests/test_device_init.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,78 @@ | ||||
| """Device init tests""" | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
| from authentik.providers.oauth2.views.device_init import QS_KEY_CODE | ||||
|  | ||||
|  | ||||
| class TesOAuth2DeviceInit(OAuthTestCase): | ||||
|     """Test device init""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.provider = OAuth2Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|             client_id="test", | ||||
|             authorization_flow=create_test_flow(), | ||||
|         ) | ||||
|         self.application = Application.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             provider=self.provider, | ||||
|         ) | ||||
|         self.user = create_test_admin_user() | ||||
|         self.client.force_login(self.user) | ||||
|         self.device_flow = create_test_flow() | ||||
|         self.tenant = create_test_tenant() | ||||
|         self.tenant.flow_device_code = self.device_flow | ||||
|         self.tenant.save() | ||||
|  | ||||
|     def test_device_init(self): | ||||
|         """Test device init""" | ||||
|         res = self.client.get(reverse("authentik_providers_oauth2_root:device-login")) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertEqual( | ||||
|             res.url, | ||||
|             reverse( | ||||
|                 "authentik_core:if-flow", | ||||
|                 kwargs={ | ||||
|                     "flow_slug": self.device_flow.slug, | ||||
|                 }, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def test_no_flow(self): | ||||
|         """Test no flow""" | ||||
|         self.tenant.flow_device_code = None | ||||
|         self.tenant.save() | ||||
|         res = self.client.get(reverse("authentik_providers_oauth2_root:device-login")) | ||||
|         self.assertEqual(res.status_code, 404) | ||||
|  | ||||
|     def test_device_init_qs(self): | ||||
|         """Test device init""" | ||||
|         token = DeviceToken.objects.create( | ||||
|             user_code="foo", | ||||
|             provider=self.provider, | ||||
|         ) | ||||
|         res = self.client.get( | ||||
|             reverse("authentik_providers_oauth2_root:device-login") | ||||
|             + "?" | ||||
|             + urlencode({QS_KEY_CODE: token.user_code}) | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertEqual( | ||||
|             res.url, | ||||
|             reverse( | ||||
|                 "authentik_core:if-flow", | ||||
|                 kwargs={ | ||||
|                     "flow_slug": self.provider.authorization_flow.slug, | ||||
|                 }, | ||||
|             ) | ||||
|             + "?" | ||||
|             + urlencode({QS_KEY_CODE: token.user_code}), | ||||
|         ) | ||||
							
								
								
									
										83
									
								
								authentik/providers/oauth2/tests/test_token_device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								authentik/providers/oauth2/tests/test_token_device.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| """Test token view""" | ||||
| from json import loads | ||||
|  | ||||
| from django.test import RequestFactory | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| 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_code_fixed_length, generate_id, generate_key | ||||
| from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE | ||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
|  | ||||
|  | ||||
| class TestTokenDeviceCode(OAuthTestCase): | ||||
|     """Test token (device code) view""" | ||||
|  | ||||
|     @apply_blueprint("system/providers-oauth2.yaml") | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         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() | ||||
|  | ||||
|     def test_code_no_code(self): | ||||
|         """Test code without code""" | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
|             data={ | ||||
|                 "client_id": self.provider.client_id, | ||||
|                 "grant_type": GRANT_TYPE_DEVICE_CODE, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         body = loads(res.content.decode()) | ||||
|         self.assertEqual(body["error"], "invalid_grant") | ||||
|  | ||||
|     def test_code_no_user(self): | ||||
|         """Test code without user""" | ||||
|         device_token = DeviceToken.objects.create( | ||||
|             provider=self.provider, | ||||
|             user_code=generate_code_fixed_length(), | ||||
|             device_code=generate_id(), | ||||
|         ) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
|             data={ | ||||
|                 "client_id": self.provider.client_id, | ||||
|                 "grant_type": GRANT_TYPE_DEVICE_CODE, | ||||
|                 "device_code": device_token.device_code, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         body = loads(res.content.decode()) | ||||
|         self.assertEqual(body["error"], "authorization_pending") | ||||
|  | ||||
|     def test_code(self): | ||||
|         """Test code with user""" | ||||
|         device_token = DeviceToken.objects.create( | ||||
|             provider=self.provider, | ||||
|             user_code=generate_code_fixed_length(), | ||||
|             device_code=generate_id(), | ||||
|             user=self.user, | ||||
|         ) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
|             data={ | ||||
|                 "client_id": self.provider.client_id, | ||||
|                 "grant_type": GRANT_TYPE_DEVICE_CODE, | ||||
|                 "device_code": device_token.device_code, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
| @ -3,6 +3,7 @@ from django.urls import path | ||||
| from django.views.generic.base import RedirectView | ||||
|  | ||||
| from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView | ||||
| from authentik.providers.oauth2.views.device_backchannel import DeviceView | ||||
| from authentik.providers.oauth2.views.introspection import TokenIntrospectionView | ||||
| from authentik.providers.oauth2.views.jwks import JWKSView | ||||
| from authentik.providers.oauth2.views.provider import ProviderInfoView | ||||
| @ -17,6 +18,7 @@ urlpatterns = [ | ||||
|         name="authorize", | ||||
|     ), | ||||
|     path("token/", TokenView.as_view(), name="token"), | ||||
|     path("device/", DeviceView.as_view(), name="device"), | ||||
|     path( | ||||
|         "userinfo/", | ||||
|         UserInfoView.as_view(), | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| """authentik oauth_provider urls""" | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.urls import include, path | ||||
| 
 | ||||
| from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView | ||||
| from authentik.providers.oauth2.views.device_init import DeviceEntryView | ||||
| from authentik.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView | ||||
| from authentik.providers.oauth2.views.token import TokenView | ||||
| 
 | ||||
| @ -30,4 +32,11 @@ github_urlpatterns = [ | ||||
| 
 | ||||
| urlpatterns = [ | ||||
|     path("", include(github_urlpatterns)), | ||||
|     path( | ||||
|         "device", | ||||
|         login_required( | ||||
|             DeviceEntryView.as_view(), | ||||
|         ), | ||||
|         name="device-login", | ||||
|     ), | ||||
| ] | ||||
| @ -343,11 +343,10 @@ class AuthorizationFlowInitView(PolicyAccessView): | ||||
|         ): | ||||
|             self.request.session[SESSION_KEY_NEEDS_LOGIN] = True | ||||
|             return self.handle_no_permission() | ||||
|         scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope) | ||||
|         # Regardless, we start the planner and return to it | ||||
|         planner = FlowPlanner(self.provider.authorization_flow) | ||||
|         # planner.use_cache = False | ||||
|         planner.allow_empty_flows = True | ||||
|         scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope) | ||||
|         plan = planner.plan( | ||||
|             self.request, | ||||
|             { | ||||
|  | ||||
							
								
								
									
										82
									
								
								authentik/providers/oauth2/views/device_backchannel.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								authentik/providers/oauth2/views/device_backchannel.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | ||||
| """Device flow views""" | ||||
| from typing import Optional | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse | ||||
| from django.urls import reverse | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.utils.timezone import now | ||||
| from django.views import View | ||||
| from django.views.decorators.csrf import csrf_exempt | ||||
| from rest_framework.throttling import AnonRateThrottle | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | ||||
| from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @method_decorator(csrf_exempt, name="dispatch") | ||||
| class DeviceView(View): | ||||
|     """Device flow, devices can request tokens which users can verify""" | ||||
|  | ||||
|     client_id: str | ||||
|     provider: OAuth2Provider | ||||
|     scopes: list[str] = [] | ||||
|  | ||||
|     def parse_request(self) -> Optional[HttpResponse]: | ||||
|         """Parse incoming request""" | ||||
|         client_id = self.request.POST.get("client_id", None) | ||||
|         if not client_id: | ||||
|             return HttpResponseBadRequest() | ||||
|         provider = OAuth2Provider.objects.filter( | ||||
|             client_id=client_id, | ||||
|         ).first() | ||||
|         if not provider: | ||||
|             return HttpResponseBadRequest() | ||||
|         if not get_application(provider): | ||||
|             return HttpResponseBadRequest() | ||||
|         self.provider = provider | ||||
|         self.client_id = client_id | ||||
|         self.scopes = self.request.POST.get("scope", "").split(" ") | ||||
|         return None | ||||
|  | ||||
|     def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         throttle = AnonRateThrottle() | ||||
|         throttle.rate = CONFIG.y("throttle.providers.oauth2.device", "20/hour") | ||||
|         throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate) | ||||
|         if not throttle.allow_request(request, self): | ||||
|             return HttpResponse(status=429) | ||||
|         return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|     def post(self, request: HttpRequest) -> HttpResponse: | ||||
|         """Generate device token""" | ||||
|         resp = self.parse_request() | ||||
|         if resp: | ||||
|             return resp | ||||
|         until = timedelta_from_string(self.provider.access_code_validity) | ||||
|         token: DeviceToken = DeviceToken.objects.create( | ||||
|             expires=now() + until, provider=self.provider, _scope=" ".join(self.scopes) | ||||
|         ) | ||||
|         device_url = self.request.build_absolute_uri( | ||||
|             reverse("authentik_providers_oauth2_root:device-login") | ||||
|         ) | ||||
|         return JsonResponse( | ||||
|             { | ||||
|                 "device_code": token.device_code, | ||||
|                 "verification_uri": device_url, | ||||
|                 "verification_uri_complete": device_url | ||||
|                 + "?" | ||||
|                 + urlencode( | ||||
|                     { | ||||
|                         QS_KEY_CODE: token.user_code, | ||||
|                     } | ||||
|                 ), | ||||
|                 "user_code": token.user_code, | ||||
|                 "expires_in": until.total_seconds(), | ||||
|                 "interval": 5, | ||||
|             } | ||||
|         ) | ||||
							
								
								
									
										46
									
								
								authentik/providers/oauth2/views/device_finish.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								authentik/providers/oauth2/views/device_finish.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| """Device flow finish stage""" | ||||
| from django.http import HttpResponse | ||||
| from rest_framework.fields import CharField | ||||
|  | ||||
| from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | ||||
| from authentik.flows.planner import FlowPlan | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.providers.oauth2.models import DeviceToken | ||||
|  | ||||
| PLAN_CONTEXT_DEVICE = "device" | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeFinishChallenge(Challenge): | ||||
|     """Final challenge after user enters their code""" | ||||
|  | ||||
|     component = CharField(default="ak-provider-oauth2-device-code-finish") | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeFinishChallengeResponse(ChallengeResponse): | ||||
|     """Response that device has been authenticated and tab can be closed""" | ||||
|  | ||||
|     component = CharField(default="ak-provider-oauth2-device-code-finish") | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeFinishStage(ChallengeStageView): | ||||
|     """Stage show at the end of a device flow""" | ||||
|  | ||||
|     response_class = OAuthDeviceCodeFinishChallengeResponse | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] | ||||
|         token: DeviceToken = plan.context[PLAN_CONTEXT_DEVICE] | ||||
|         # As we're required to be authenticated by now, we can rely on | ||||
|         # request.user | ||||
|         token.user = self.request.user | ||||
|         token.save() | ||||
|         return OAuthDeviceCodeFinishChallenge( | ||||
|             data={ | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|                 "component": "ak-provider-oauth2-device-code-finish", | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         self.executor.stage_ok() | ||||
							
								
								
									
										146
									
								
								authentik/providers/oauth2/views/device_init.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								authentik/providers/oauth2/views/device_init.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,146 @@ | ||||
| """Device flow views""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views import View | ||||
| from rest_framework.exceptions import ErrorDetail | ||||
| from rest_framework.fields import CharField, IntegerField | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider | ||||
| from authentik.providers.oauth2.views.device_finish import ( | ||||
|     PLAN_CONTEXT_DEVICE, | ||||
|     OAuthDeviceCodeFinishStage, | ||||
| ) | ||||
| from authentik.providers.oauth2.views.userinfo import UserInfoView | ||||
| from authentik.stages.consent.stage import ( | ||||
|     PLAN_CONTEXT_CONSENT_HEADER, | ||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||
| ) | ||||
| from authentik.tenants.models import Tenant | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| QS_KEY_CODE = "code"  # nosec | ||||
|  | ||||
|  | ||||
| def get_application(provider: OAuth2Provider) -> Optional[Application]: | ||||
|     """Get application from provider""" | ||||
|     try: | ||||
|         app = provider.application | ||||
|         if not app: | ||||
|             return None | ||||
|         return app | ||||
|     except Application.DoesNotExist: | ||||
|         return None | ||||
|  | ||||
|  | ||||
| def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]: | ||||
|     """Validate user token""" | ||||
|     token = DeviceToken.objects.filter( | ||||
|         user_code=code, | ||||
|     ).first() | ||||
|     if not token: | ||||
|         return None | ||||
|  | ||||
|     app = get_application(token.provider) | ||||
|     if not app: | ||||
|         return None | ||||
|  | ||||
|     scope_descriptions = UserInfoView().get_scope_descriptions(token.scope) | ||||
|     planner = FlowPlanner(token.provider.authorization_flow) | ||||
|     planner.allow_empty_flows = True | ||||
|     plan = planner.plan( | ||||
|         request, | ||||
|         { | ||||
|             PLAN_CONTEXT_SSO: True, | ||||
|             PLAN_CONTEXT_APPLICATION: app, | ||||
|             # OAuth2 related params | ||||
|             PLAN_CONTEXT_DEVICE: token, | ||||
|             # Consent related params | ||||
|             PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.") | ||||
|             % {"application": app.name}, | ||||
|             PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions, | ||||
|         }, | ||||
|     ) | ||||
|     plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) | ||||
|     request.session[SESSION_KEY_PLAN] = plan | ||||
|     return redirect_with_qs( | ||||
|         "authentik_core:if-flow", | ||||
|         request.GET, | ||||
|         flow_slug=token.provider.authorization_flow.slug, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class DeviceEntryView(View): | ||||
|     """View used to initiate the device-code flow, url entered by endusers""" | ||||
|  | ||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||
|         tenant: Tenant = request.tenant | ||||
|         device_flow = tenant.flow_device_code | ||||
|         if not device_flow: | ||||
|             LOGGER.info("Tenant has no device code flow configured", tenant=tenant) | ||||
|             return HttpResponse(status=404) | ||||
|         if QS_KEY_CODE in request.GET: | ||||
|             validation = validate_code(request.GET[QS_KEY_CODE], request) | ||||
|             if validation: | ||||
|                 return validation | ||||
|             LOGGER.info("Got code from query parameter but no matching token found") | ||||
|  | ||||
|         # Regardless, we start the planner and return to it | ||||
|         planner = FlowPlanner(device_flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         plan = planner.plan(self.request) | ||||
|         plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) | ||||
|  | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=device_flow.slug, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeChallenge(Challenge): | ||||
|     """OAuth Device code challenge""" | ||||
|  | ||||
|     component = CharField(default="ak-provider-oauth2-device-code") | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeChallengeResponse(ChallengeResponse): | ||||
|     """Response that includes the user-entered device code""" | ||||
|  | ||||
|     code = IntegerField() | ||||
|     component = CharField(default="ak-provider-oauth2-device-code") | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeStage(ChallengeStageView): | ||||
|     """Flow challenge for users to enter device codes""" | ||||
|  | ||||
|     response_class = OAuthDeviceCodeChallengeResponse | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         return OAuthDeviceCodeChallenge( | ||||
|             data={ | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|                 "component": "ak-provider-oauth2-device-code", | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         code = response.validated_data["code"] | ||||
|         validation = validate_code(code, self.request) | ||||
|         if not validation: | ||||
|             response._errors.setdefault("code", []) | ||||
|             response._errors["code"].append(ErrorDetail(_("Invalid code"), "invalid")) | ||||
|             return self.challenge_invalid(response) | ||||
|         # Run cancel to cleanup the current flow | ||||
|         self.executor.cancel() | ||||
|         return validation | ||||
| @ -11,6 +11,7 @@ from authentik.providers.oauth2.constants import ( | ||||
|     ACR_AUTHENTIK_DEFAULT, | ||||
|     GRANT_TYPE_AUTHORIZATION_CODE, | ||||
|     GRANT_TYPE_CLIENT_CREDENTIALS, | ||||
|     GRANT_TYPE_DEVICE_CODE, | ||||
|     GRANT_TYPE_IMPLICIT, | ||||
|     GRANT_TYPE_PASSWORD, | ||||
|     GRANT_TYPE_REFRESH_TOKEN, | ||||
| @ -61,6 +62,9 @@ class ProviderInfoView(View): | ||||
|             "revocation_endpoint": self.request.build_absolute_uri( | ||||
|                 reverse("authentik_providers_oauth2:token-revoke") | ||||
|             ), | ||||
|             "device_authorization_endpoint": self.request.build_absolute_uri( | ||||
|                 reverse("authentik_providers_oauth2:device") | ||||
|             ), | ||||
|             "response_types_supported": [ | ||||
|                 ResponseTypes.CODE, | ||||
|                 ResponseTypes.ID_TOKEN, | ||||
| @ -81,6 +85,7 @@ class ProviderInfoView(View): | ||||
|                 GRANT_TYPE_IMPLICIT, | ||||
|                 GRANT_TYPE_CLIENT_CREDENTIALS, | ||||
|                 GRANT_TYPE_PASSWORD, | ||||
|                 GRANT_TYPE_DEVICE_CODE, | ||||
|             ], | ||||
|             "id_token_signing_alg_values_supported": [supported_alg], | ||||
|             # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes | ||||
|  | ||||
| @ -32,13 +32,15 @@ from authentik.providers.oauth2.constants import ( | ||||
|     CLIENT_ASSERTION_TYPE_JWT, | ||||
|     GRANT_TYPE_AUTHORIZATION_CODE, | ||||
|     GRANT_TYPE_CLIENT_CREDENTIALS, | ||||
|     GRANT_TYPE_DEVICE_CODE, | ||||
|     GRANT_TYPE_PASSWORD, | ||||
|     GRANT_TYPE_REFRESH_TOKEN, | ||||
| ) | ||||
| from authentik.providers.oauth2.errors import TokenError, UserAuthError | ||||
| from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError | ||||
| from authentik.providers.oauth2.models import ( | ||||
|     AuthorizationCode, | ||||
|     ClientTypes, | ||||
|     DeviceToken, | ||||
|     OAuth2Provider, | ||||
|     RefreshToken, | ||||
| ) | ||||
| @ -64,6 +66,7 @@ class TokenParams: | ||||
|  | ||||
|     authorization_code: Optional[AuthorizationCode] = None | ||||
|     refresh_token: Optional[RefreshToken] = None | ||||
|     device_code: Optional[DeviceToken] = None | ||||
|     user: Optional[User] = None | ||||
|  | ||||
|     code_verifier: Optional[str] = None | ||||
| @ -139,6 +142,11 @@ class TokenParams: | ||||
|                 op="authentik.providers.oauth2.post.parse.client_credentials", | ||||
|             ): | ||||
|                 self.__post_init_client_credentials(request) | ||||
|         elif self.grant_type == GRANT_TYPE_DEVICE_CODE: | ||||
|             with Hub.current.start_span( | ||||
|                 op="authentik.providers.oauth2.post.parse.device_code", | ||||
|             ): | ||||
|                 self.__post_init_device_code(request) | ||||
|         else: | ||||
|             LOGGER.warning("Invalid grant type", grant_type=self.grant_type) | ||||
|             raise TokenError("unsupported_grant_type") | ||||
| @ -347,6 +355,13 @@ class TokenParams: | ||||
|             PLAN_CONTEXT_APPLICATION=app, | ||||
|         ).from_http(request, user=self.user) | ||||
|  | ||||
|     def __post_init_device_code(self, request: HttpRequest): | ||||
|         device_code = request.POST.get("device_code", "") | ||||
|         code = DeviceToken.objects.filter(device_code=device_code, provider=self.provider).first() | ||||
|         if not code: | ||||
|             raise TokenError("invalid_grant") | ||||
|         self.device_code = code | ||||
|  | ||||
|     def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource): | ||||
|         """Create user from JWT""" | ||||
|         exp = token.get("exp") | ||||
| @ -413,8 +428,11 @@ class TokenView(View): | ||||
|                 if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS: | ||||
|                     LOGGER.debug("Client credentials grant") | ||||
|                     return TokenResponse(self.create_client_credentials_response()) | ||||
|                 if self.params.grant_type == GRANT_TYPE_DEVICE_CODE: | ||||
|                     LOGGER.debug("Device code grant") | ||||
|                     return TokenResponse(self.create_device_code_response()) | ||||
|                 raise ValueError(f"Invalid grant_type: {self.params.grant_type}") | ||||
|         except TokenError as error: | ||||
|         except (TokenError, DeviceCodeError) as error: | ||||
|             return TokenResponse(error.create_dict(), status=400) | ||||
|         except UserAuthError as error: | ||||
|             return TokenResponse(error.create_dict(), status=403) | ||||
| @ -507,3 +525,31 @@ class TokenView(View): | ||||
|             "expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), | ||||
|             "id_token": self.provider.encode(refresh_token.id_token.to_dict()), | ||||
|         } | ||||
|  | ||||
|     def create_device_code_response(self) -> dict[str, Any]: | ||||
|         """See https://datatracker.ietf.org/doc/html/rfc8628""" | ||||
|         if not self.params.device_code.user: | ||||
|             raise DeviceCodeError("authorization_pending") | ||||
|  | ||||
|         refresh_token: RefreshToken = self.provider.create_refresh_token( | ||||
|             user=self.params.device_code.user, | ||||
|             scope=self.params.device_code.scope, | ||||
|             request=self.request, | ||||
|         ) | ||||
|         refresh_token.id_token = refresh_token.create_id_token( | ||||
|             user=self.params.device_code.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.provider.encode(refresh_token.id_token.to_dict()), | ||||
|         } | ||||
|  | ||||
| @ -22,7 +22,7 @@ class AppleLoginChallenge(Challenge): | ||||
|     """Special challenge for apple-native authentication flow, which happens on the client.""" | ||||
|  | ||||
|     client_id = CharField() | ||||
|     component = CharField(default="ak-flow-sources-oauth-apple") | ||||
|     component = CharField(default="ak-source-oauth-apple") | ||||
|     scope = CharField() | ||||
|     redirect_uri = CharField() | ||||
|     state = CharField() | ||||
| @ -31,7 +31,7 @@ class AppleLoginChallenge(Challenge): | ||||
| class AppleChallengeResponse(ChallengeResponse): | ||||
|     """Pseudo class for plex response""" | ||||
|  | ||||
|     component = CharField(default="ak-flow-sources-oauth-apple") | ||||
|     component = CharField(default="ak-source-oauth-apple") | ||||
|  | ||||
|  | ||||
| class AppleOAuthClient(OAuth2Client): | ||||
|  | ||||
| @ -20,13 +20,13 @@ class PlexAuthenticationChallenge(Challenge): | ||||
|  | ||||
|     client_id = CharField() | ||||
|     slug = CharField() | ||||
|     component = CharField(default="ak-flow-sources-plex") | ||||
|     component = CharField(default="ak-source-plex") | ||||
|  | ||||
|  | ||||
| class PlexAuthenticationChallengeResponse(ChallengeResponse): | ||||
|     """Pseudo class for plex response""" | ||||
|  | ||||
|     component = CharField(default="ak-flow-sources-plex") | ||||
|     component = CharField(default="ak-source-plex") | ||||
|  | ||||
|  | ||||
| class PlexSource(Source): | ||||
| @ -68,7 +68,7 @@ class PlexSource(Source): | ||||
|             challenge=PlexAuthenticationChallenge( | ||||
|                 { | ||||
|                     "type": ChallengeTypes.NATIVE.value, | ||||
|                     "component": "ak-flow-sources-plex", | ||||
|                     "component": "ak-source-plex", | ||||
|                     "client_id": self.client_id, | ||||
|                     "slug": self.slug, | ||||
|                 } | ||||
|  | ||||
| @ -154,7 +154,7 @@ class PasswordStageView(ChallengeStageView): | ||||
|         else: | ||||
|             if not user: | ||||
|                 # No user was found -> invalid credentials | ||||
|                 self.logger.debug("Invalid credentials") | ||||
|                 self.logger.info("Invalid credentials") | ||||
|                 # Manually inject error into form | ||||
|                 response._errors.setdefault("password", []) | ||||
|                 response._errors["password"].append(ErrorDetail(_("Invalid password"), "invalid")) | ||||
|  | ||||
| @ -51,6 +51,7 @@ class TenantSerializer(ModelSerializer): | ||||
|             "flow_recovery", | ||||
|             "flow_unenrollment", | ||||
|             "flow_user_settings", | ||||
|             "flow_device_code", | ||||
|             "event_retention", | ||||
|             "web_certificate", | ||||
|             "attributes", | ||||
| @ -75,6 +76,7 @@ class CurrentTenantSerializer(PassiveSerializer): | ||||
|     flow_recovery = CharField(source="flow_recovery.slug", required=False) | ||||
|     flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False) | ||||
|     flow_user_settings = CharField(source="flow_user_settings.slug", required=False) | ||||
|     flow_device_code = CharField(source="flow_device_code.slug", required=False) | ||||
|  | ||||
|     default_locale = CharField(read_only=True) | ||||
|  | ||||
| @ -101,6 +103,7 @@ class TenantViewSet(UsedByMixin, ModelViewSet): | ||||
|         "flow_recovery", | ||||
|         "flow_unenrollment", | ||||
|         "flow_user_settings", | ||||
|         "flow_device_code", | ||||
|         "event_retention", | ||||
|         "web_certificate", | ||||
|     ] | ||||
|  | ||||
							
								
								
									
										25
									
								
								authentik/tenants/migrations/0004_tenant_flow_device_code.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/tenants/migrations/0004_tenant_flow_device_code.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| # Generated by Django 4.1 on 2022-09-03 21:16 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0023_flow_denied_action"), | ||||
|         ("authentik_tenants", "0003_tenant_attributes"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="tenant", | ||||
|             name="flow_device_code", | ||||
|             field=models.ForeignKey( | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="tenant_device_code", | ||||
|                 to="authentik_flows.flow", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -48,6 +48,9 @@ class Tenant(SerializerModel): | ||||
|     flow_user_settings = models.ForeignKey( | ||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings" | ||||
|     ) | ||||
|     flow_device_code = models.ForeignKey( | ||||
|         Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_device_code" | ||||
|     ) | ||||
|  | ||||
|     event_retention = models.TextField( | ||||
|         default="days=365", | ||||
|  | ||||
							
								
								
									
										105
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										105
									
								
								schema.yml
									
									
									
									
									
								
							| @ -3529,6 +3529,11 @@ paths: | ||||
|         schema: | ||||
|           type: string | ||||
|           format: uuid | ||||
|       - in: query | ||||
|         name: flow_device_code | ||||
|         schema: | ||||
|           type: string | ||||
|           format: uuid | ||||
|       - in: query | ||||
|         name: flow_invalidation | ||||
|         schema: | ||||
| @ -24616,7 +24621,7 @@ components: | ||||
|         component: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|           default: ak-flow-sources-oauth-apple | ||||
|           default: ak-source-oauth-apple | ||||
|     AppleLoginChallenge: | ||||
|       type: object | ||||
|       description: Special challenge for apple-native authentication flow, which happens | ||||
| @ -24628,7 +24633,7 @@ components: | ||||
|           $ref: '#/components/schemas/ContextualFlowInfo' | ||||
|         component: | ||||
|           type: string | ||||
|           default: ak-flow-sources-oauth-apple | ||||
|           default: ak-source-oauth-apple | ||||
|         response_errors: | ||||
|           type: object | ||||
|           additionalProperties: | ||||
| @ -26028,6 +26033,8 @@ components: | ||||
|       - $ref: '#/components/schemas/EmailChallenge' | ||||
|       - $ref: '#/components/schemas/FlowErrorChallenge' | ||||
|       - $ref: '#/components/schemas/IdentificationChallenge' | ||||
|       - $ref: '#/components/schemas/OAuthDeviceCodeChallenge' | ||||
|       - $ref: '#/components/schemas/OAuthDeviceCodeFinishChallenge' | ||||
|       - $ref: '#/components/schemas/PasswordChallenge' | ||||
|       - $ref: '#/components/schemas/PlexAuthenticationChallenge' | ||||
|       - $ref: '#/components/schemas/PromptChallenge' | ||||
| @ -26037,7 +26044,7 @@ components: | ||||
|         propertyName: component | ||||
|         mapping: | ||||
|           ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge' | ||||
|           ak-flow-sources-oauth-apple: '#/components/schemas/AppleLoginChallenge' | ||||
|           ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge' | ||||
|           ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallenge' | ||||
|           ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge' | ||||
|           ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge' | ||||
| @ -26051,8 +26058,10 @@ components: | ||||
|           ak-stage-email: '#/components/schemas/EmailChallenge' | ||||
|           xak-flow-error: '#/components/schemas/FlowErrorChallenge' | ||||
|           ak-stage-identification: '#/components/schemas/IdentificationChallenge' | ||||
|           ak-provider-oauth2-device-code: '#/components/schemas/OAuthDeviceCodeChallenge' | ||||
|           ak-provider-oauth2-device-code-finish: '#/components/schemas/OAuthDeviceCodeFinishChallenge' | ||||
|           ak-stage-password: '#/components/schemas/PasswordChallenge' | ||||
|           ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallenge' | ||||
|           ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge' | ||||
|           ak-stage-prompt: '#/components/schemas/PromptChallenge' | ||||
|           xak-flow-redirect: '#/components/schemas/RedirectChallenge' | ||||
|           xak-flow-shell: '#/components/schemas/ShellChallenge' | ||||
| @ -26261,6 +26270,8 @@ components: | ||||
|           type: string | ||||
|         flow_user_settings: | ||||
|           type: string | ||||
|         flow_device_code: | ||||
|           type: string | ||||
|         default_locale: | ||||
|           type: string | ||||
|           readOnly: true | ||||
| @ -27240,13 +27251,15 @@ components: | ||||
|       - $ref: '#/components/schemas/DummyChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/EmailChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/IdentificationChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/OAuthDeviceCodeChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/OAuthDeviceCodeFinishChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/PasswordChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' | ||||
|       - $ref: '#/components/schemas/PromptChallengeResponseRequest' | ||||
|       discriminator: | ||||
|         propertyName: component | ||||
|         mapping: | ||||
|           ak-flow-sources-oauth-apple: '#/components/schemas/AppleChallengeResponseRequest' | ||||
|           ak-source-oauth-apple: '#/components/schemas/AppleChallengeResponseRequest' | ||||
|           ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest' | ||||
|           ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest' | ||||
|           ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest' | ||||
| @ -27259,8 +27272,10 @@ components: | ||||
|           ak-stage-dummy: '#/components/schemas/DummyChallengeResponseRequest' | ||||
|           ak-stage-email: '#/components/schemas/EmailChallengeResponseRequest' | ||||
|           ak-stage-identification: '#/components/schemas/IdentificationChallengeResponseRequest' | ||||
|           ak-provider-oauth2-device-code: '#/components/schemas/OAuthDeviceCodeChallengeResponseRequest' | ||||
|           ak-provider-oauth2-device-code-finish: '#/components/schemas/OAuthDeviceCodeFinishChallengeResponseRequest' | ||||
|           ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest' | ||||
|           ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' | ||||
|           ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' | ||||
|           ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest' | ||||
|     FlowDesignationEnum: | ||||
|       enum: | ||||
| @ -28632,8 +28647,8 @@ components: | ||||
|         propertyName: component | ||||
|         mapping: | ||||
|           xak-flow-redirect: '#/components/schemas/RedirectChallenge' | ||||
|           ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallenge' | ||||
|           ak-flow-sources-oauth-apple: '#/components/schemas/AppleLoginChallenge' | ||||
|           ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge' | ||||
|           ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge' | ||||
|     LoginMetrics: | ||||
|       type: object | ||||
|       description: Login Metrics per 1h | ||||
| @ -29096,6 +29111,64 @@ components: | ||||
|       - provider_info | ||||
|       - token | ||||
|       - user_info | ||||
|     OAuthDeviceCodeChallenge: | ||||
|       type: object | ||||
|       description: OAuth Device code challenge | ||||
|       properties: | ||||
|         type: | ||||
|           $ref: '#/components/schemas/ChallengeChoices' | ||||
|         flow_info: | ||||
|           $ref: '#/components/schemas/ContextualFlowInfo' | ||||
|         component: | ||||
|           type: string | ||||
|           default: ak-provider-oauth2-device-code | ||||
|         response_errors: | ||||
|           type: object | ||||
|           additionalProperties: | ||||
|             type: array | ||||
|             items: | ||||
|               $ref: '#/components/schemas/ErrorDetail' | ||||
|       required: | ||||
|       - type | ||||
|     OAuthDeviceCodeChallengeResponseRequest: | ||||
|       type: object | ||||
|       description: Response that includes the user-entered device code | ||||
|       properties: | ||||
|         component: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|           default: ak-provider-oauth2-device-code | ||||
|         code: | ||||
|           type: integer | ||||
|       required: | ||||
|       - code | ||||
|     OAuthDeviceCodeFinishChallenge: | ||||
|       type: object | ||||
|       description: Final challenge after user enters their code | ||||
|       properties: | ||||
|         type: | ||||
|           $ref: '#/components/schemas/ChallengeChoices' | ||||
|         flow_info: | ||||
|           $ref: '#/components/schemas/ContextualFlowInfo' | ||||
|         component: | ||||
|           type: string | ||||
|           default: ak-provider-oauth2-device-code-finish | ||||
|         response_errors: | ||||
|           type: object | ||||
|           additionalProperties: | ||||
|             type: array | ||||
|             items: | ||||
|               $ref: '#/components/schemas/ErrorDetail' | ||||
|       required: | ||||
|       - type | ||||
|     OAuthDeviceCodeFinishChallengeResponseRequest: | ||||
|       type: object | ||||
|       description: Response that device has been authenticated and tab can be closed | ||||
|       properties: | ||||
|         component: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|           default: ak-provider-oauth2-device-code-finish | ||||
|     OAuthSource: | ||||
|       type: object | ||||
|       description: OAuth Source Serializer | ||||
| @ -34205,6 +34278,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         flow_device_code: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         event_retention: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
| @ -34384,7 +34461,7 @@ components: | ||||
|           $ref: '#/components/schemas/ContextualFlowInfo' | ||||
|         component: | ||||
|           type: string | ||||
|           default: ak-flow-sources-plex | ||||
|           default: ak-source-plex | ||||
|         response_errors: | ||||
|           type: object | ||||
|           additionalProperties: | ||||
| @ -34406,7 +34483,7 @@ components: | ||||
|         component: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|           default: ak-flow-sources-plex | ||||
|           default: ak-source-plex | ||||
|     PlexSource: | ||||
|       type: object | ||||
|       description: Plex Source Serializer | ||||
| @ -36703,6 +36780,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         flow_device_code: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         event_retention: | ||||
|           type: string | ||||
|           description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).' | ||||
| @ -36757,6 +36838,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         flow_device_code: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         event_retention: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|  | ||||
| @ -46,12 +46,12 @@ class TestProviderOAuth2Github(SeleniumTestCase): | ||||
|                 "GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret, | ||||
|                 "GF_AUTH_GITHUB_SCOPES": "user:email,read:org", | ||||
|                 "GF_AUTH_GITHUB_AUTH_URL": self.url( | ||||
|                     "authentik_providers_oauth2_github:github-authorize" | ||||
|                     "authentik_providers_oauth2_root:github-authorize" | ||||
|                 ), | ||||
|                 "GF_AUTH_GITHUB_TOKEN_URL": self.url( | ||||
|                     "authentik_providers_oauth2_github:github-access-token" | ||||
|                     "authentik_providers_oauth2_root:github-access-token" | ||||
|                 ), | ||||
|                 "GF_AUTH_GITHUB_API_URL": self.url("authentik_providers_oauth2_github:github-user"), | ||||
|                 "GF_AUTH_GITHUB_API_URL": self.url("authentik_providers_oauth2_root:github-user"), | ||||
|                 "GF_LOG_LEVEL": "debug", | ||||
|             }, | ||||
|         } | ||||
|  | ||||
| @ -314,6 +314,40 @@ export class TenantForm extends ModelForm<Tenant, string> { | ||||
|                             ${t`If set, users are able to configure details of their profile.`} | ||||
|                         </p> | ||||
|                     </ak-form-element-horizontal> | ||||
|                     <ak-form-element-horizontal label=${t`Device code flow`} name="flowDeviceCode"> | ||||
|                         <select class="pf-c-form-control"> | ||||
|                             <option | ||||
|                                 value="" | ||||
|                                 ?selected=${this.instance?.flowDeviceCode === undefined} | ||||
|                             > | ||||
|                                 --------- | ||||
|                             </option> | ||||
|                             ${until( | ||||
|                                 new FlowsApi(DEFAULT_CONFIG) | ||||
|                                     .flowsInstancesList({ | ||||
|                                         ordering: "slug", | ||||
|                                         designation: | ||||
|                                             FlowsInstancesListDesignationEnum.StageConfiguration, | ||||
|                                     }) | ||||
|                                     .then((flows) => { | ||||
|                                         return flows.results.map((flow) => { | ||||
|                                             const selected = | ||||
|                                                 this.instance?.flowDeviceCode === flow.pk; | ||||
|                                             return html`<option | ||||
|                                                 value=${flow.pk} | ||||
|                                                 ?selected=${selected} | ||||
|                                             > | ||||
|                                                 ${flow.name} (${flow.slug}) | ||||
|                                             </option>`; | ||||
|                                         }); | ||||
|                                     }), | ||||
|                                 html`<option>${t`Loading...`}</option>`, | ||||
|                             )} | ||||
|                         </select> | ||||
|                         <p class="pf-c-form__helper-text"> | ||||
|                             ${t`If set, the OAuth Device Code profile can be used, and the selected flow will be used to enter the code.`} | ||||
|                         </p> | ||||
|                     </ak-form-element-horizontal> | ||||
|                 </div> | ||||
|             </ak-form-group> | ||||
|             <ak-form-group> | ||||
|  | ||||
| @ -357,18 +357,32 @@ export class FlowExecutor extends AKElement implements StageHost { | ||||
|                     .host=${this as StageHost} | ||||
|                     .challenge=${this.challenge} | ||||
|                 ></ak-stage-authenticator-validate>`; | ||||
|             case "ak-flow-sources-plex": | ||||
|             // Sources | ||||
|             case "ak-source-plex": | ||||
|                 await import("@goauthentik/flow/sources/plex/PlexLoginInit"); | ||||
|                 return html`<ak-flow-sources-plex | ||||
|                 return html`<ak-flow-source-plex | ||||
|                     .host=${this as StageHost} | ||||
|                     .challenge=${this.challenge} | ||||
|                 ></ak-flow-sources-plex>`; | ||||
|             case "ak-flow-sources-oauth-apple": | ||||
|                 ></ak-flow-source-plex>`; | ||||
|             case "ak-source-oauth-apple": | ||||
|                 await import("@goauthentik/flow/sources/apple/AppleLoginInit"); | ||||
|                 return html`<ak-flow-sources-oauth-apple | ||||
|                 return html`<ak-flow-source-oauth-apple | ||||
|                     .host=${this as StageHost} | ||||
|                     .challenge=${this.challenge} | ||||
|                 ></ak-flow-sources-oauth-apple>`; | ||||
|                 ></ak-flow-source-oauth-apple>`; | ||||
|             // Providers | ||||
|             case "ak-provider-oauth2-device-code": | ||||
|                 await import("@goauthentik/flow/providers/oauth2/DeviceCode"); | ||||
|                 return html`<ak-flow-provider-oauth2-code | ||||
|                     .host=${this as StageHost} | ||||
|                     .challenge=${this.challenge} | ||||
|                 ></ak-flow-provider-oauth2-code>`; | ||||
|             case "ak-provider-oauth2-device-code-finish": | ||||
|                 await import("@goauthentik/flow/providers/oauth2/DeviceCodeFinish"); | ||||
|                 return html`<ak-flow-provider-oauth2-code-finish | ||||
|                     .host=${this as StageHost} | ||||
|                     .challenge=${this.challenge} | ||||
|                 ></ak-flow-provider-oauth2-code-finish>`; | ||||
|             default: | ||||
|                 break; | ||||
|         } | ||||
|  | ||||
							
								
								
									
										80
									
								
								web/src/flow/providers/oauth2/DeviceCode.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								web/src/flow/providers/oauth2/DeviceCode.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| import "@goauthentik/elements/EmptyState"; | ||||
| import "@goauthentik/elements/forms/FormElement"; | ||||
| import "@goauthentik/flow/FormStatic"; | ||||
| import { BaseStage } from "@goauthentik/flow/stages/base"; | ||||
|  | ||||
| import { t } from "@lingui/macro"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, html } from "lit"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| import AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| import PFForm from "@patternfly/patternfly/components/Form/form.css"; | ||||
| import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | ||||
| import PFLogin from "@patternfly/patternfly/components/Login/login.css"; | ||||
| import PFTitle from "@patternfly/patternfly/components/Title/title.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { | ||||
|     OAuthDeviceCodeChallenge, | ||||
|     OAuthDeviceCodeChallengeResponseRequest, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-flow-provider-oauth2-code") | ||||
| export class OAuth2DeviceCode extends BaseStage< | ||||
|     OAuthDeviceCodeChallenge, | ||||
|     OAuthDeviceCodeChallengeResponseRequest | ||||
| > { | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal]; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         if (!this.challenge) { | ||||
|             return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`; | ||||
|         } | ||||
|         return html`<header class="pf-c-login__main-header"> | ||||
|                 <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> | ||||
|             </header> | ||||
|             <div class="pf-c-login__main-body"> | ||||
|                 <form | ||||
|                     class="pf-c-form" | ||||
|                     @submit=${(e: Event) => { | ||||
|                         this.submitForm(e); | ||||
|                     }} | ||||
|                 > | ||||
|                     <p>${t`Enter the code shown on your device.`}</p> | ||||
|                     <ak-form-element | ||||
|                         label="${t`Code`}" | ||||
|                         ?required="${true}" | ||||
|                         class="pf-c-form__group" | ||||
|                         .errors=${(this.challenge?.responseErrors || {})["code"]} | ||||
|                     > | ||||
|                         <!-- @ts-ignore --> | ||||
|                         <input | ||||
|                             type="text" | ||||
|                             name="code" | ||||
|                             inputmode="numeric" | ||||
|                             pattern="[0-9]*" | ||||
|                             placeholder="${t`Please enter your Code`}" | ||||
|                             autofocus="" | ||||
|                             autocomplete="off" | ||||
|                             class="pf-c-form-control" | ||||
|                             value="" | ||||
|                             required | ||||
|                         /> | ||||
|                     </ak-form-element> | ||||
|  | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|                         <button type="submit" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|                             ${t`Continue`} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|             <footer class="pf-c-login__main-footer"> | ||||
|                 <ul class="pf-c-login__main-footer-links"></ul> | ||||
|             </footer>`; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								web/src/flow/providers/oauth2/DeviceCodeFinish.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								web/src/flow/providers/oauth2/DeviceCodeFinish.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | ||||
| import "@goauthentik/elements/EmptyState"; | ||||
| import "@goauthentik/flow/FormStatic"; | ||||
| import { BaseStage } from "@goauthentik/flow/stages/base"; | ||||
|  | ||||
| import { t } from "@lingui/macro"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, html } from "lit"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| import AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||
| import PFForm from "@patternfly/patternfly/components/Form/form.css"; | ||||
| import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | ||||
| import PFList from "@patternfly/patternfly/components/List/list.css"; | ||||
| import PFLogin from "@patternfly/patternfly/components/Login/login.css"; | ||||
| import PFTitle from "@patternfly/patternfly/components/Title/title.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { | ||||
|     OAuthDeviceCodeFinishChallenge, | ||||
|     OAuthDeviceCodeFinishChallengeResponseRequest, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-flow-provider-oauth2-code-finish") | ||||
| export class DeviceCodeFinish extends BaseStage< | ||||
|     OAuthDeviceCodeFinishChallenge, | ||||
|     OAuthDeviceCodeFinishChallengeResponseRequest | ||||
| > { | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [PFBase, PFLogin, PFForm, PFList, PFFormControl, PFTitle, AKGlobal]; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         if (!this.challenge) { | ||||
|             return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`; | ||||
|         } | ||||
|         return html`<header class="pf-c-login__main-header"> | ||||
|                 <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> | ||||
|             </header> | ||||
|             <div class="pf-c-login__main-body"> | ||||
|                 <form class="pf-c-form"> | ||||
|                     <div class="pf-c-form__group"> | ||||
|                         <p> | ||||
|                             <i class="pf-icon pf-icon-ok"></i> | ||||
|                             ${t`You've successfully authenticated your device.`} | ||||
|                         </p> | ||||
|                         <hr /> | ||||
|                         <p>${t`You can close this tab now.`}</p> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|             <footer class="pf-c-login__main-footer"> | ||||
|                 <ul class="pf-c-login__main-footer-links"></ul> | ||||
|             </footer>`; | ||||
|     } | ||||
| } | ||||
| @ -16,7 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { AppleChallengeResponseRequest, AppleLoginChallenge } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-flow-sources-oauth-apple") | ||||
| @customElement("ak-flow-source-oauth-apple") | ||||
| export class AppleLoginInit extends BaseStage<AppleLoginChallenge, AppleChallengeResponseRequest> { | ||||
|     @property({ type: Boolean }) | ||||
|     isModalShown = false; | ||||
|  | ||||
| @ -25,7 +25,7 @@ import { | ||||
| } from "@goauthentik/api"; | ||||
| import { SourcesApi } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-flow-sources-plex") | ||||
| @customElement("ak-flow-source-plex") | ||||
| export class PlexLoginInit extends BaseStage< | ||||
|     PlexAuthenticationChallenge, | ||||
|     PlexAuthenticationChallengeResponseRequest | ||||
|  | ||||
| @ -112,10 +112,10 @@ export class LibraryPage extends AKElement { | ||||
|         </div>`; | ||||
|     } | ||||
|  | ||||
|     getApps(): [string, Application[]][] { | ||||
|         return groupBy( | ||||
|     filterApps(): Application[] { | ||||
|         return ( | ||||
|             this.apps?.results.filter((app) => { | ||||
|                 if (app.launchUrl) { | ||||
|                 if (app.launchUrl && app.launchUrl !== "") { | ||||
|                     // If the launch URL is a full URL, only show with http or https | ||||
|                     if (app.launchUrl.indexOf("://") !== -1) { | ||||
|                         return app.launchUrl.startsWith("http"); | ||||
| @ -124,11 +124,14 @@ export class LibraryPage extends AKElement { | ||||
|                     return true; | ||||
|                 } | ||||
|                 return false; | ||||
|             }) || [], | ||||
|             (app) => app.group || "", | ||||
|             }) || [] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     getApps(): [string, Application[]][] { | ||||
|         return groupBy(this.filterApps(), (app) => app.group || ""); | ||||
|     } | ||||
|  | ||||
|     renderApps(config: UIConfig): TemplateResult { | ||||
|         let groupClass = ""; | ||||
|         let groupGrid = ""; | ||||
| @ -215,9 +218,7 @@ export class LibraryPage extends AKElement { | ||||
|                     <section class="pf-c-page__main-section"> | ||||
|                         ${loading( | ||||
|                             this.apps, | ||||
|                             html`${(this.apps?.results || []).filter((app) => { | ||||
|                                 return app.launchUrl !== null; | ||||
|                             }).length > 0 | ||||
|                             html`${this.filterApps().length > 0 | ||||
|                                 ? this.renderApps(config) | ||||
|                                 : this.renderEmptyState()}`, | ||||
|                         )} | ||||
|  | ||||
							
								
								
									
										49
									
								
								website/docs/providers/oauth2/device_code.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								website/docs/providers/oauth2/device_code.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | ||||
| # Device code flow | ||||
|  | ||||
| (Also known as device flow and RFC 8628) | ||||
|  | ||||
| This type of authentication flow is useful for devices with limited input abilities and/or devices without browsers. | ||||
|  | ||||
| ### Requirements | ||||
|  | ||||
| This device flow is only possible if the active tenant has a device code flow setup. This device code flow is run _after_ the user logs in, and before the user authenticates. | ||||
|  | ||||
| ### Device-side | ||||
|  | ||||
| The flow is initiated by sending a POST request to the device authorization endpoint, `/application/o/device/` with the following contents: | ||||
|  | ||||
| ``` | ||||
| POST /application/o/device/ HTTP/1.1 | ||||
| Host: authentik.company | ||||
| Content-Type: application/x-www-form-urlencoded | ||||
|  | ||||
| client_id=application_client_id& | ||||
| scopes=openid email my-other-scope | ||||
| ``` | ||||
|  | ||||
| The response contains the following fields: | ||||
|  | ||||
| -   `device_code`: Device code, which is the code kept on the device | ||||
| -   `verification_uri`: The URL to be shown to the enduser to input the code | ||||
| -   `verification_uri_complete`: The same URL as above except the code will be prefilled | ||||
| -   `user_code`: The raw code for the enduser to input | ||||
| -   `expires_in`: The total seconds after which this token will expire | ||||
| -   `interval`: The interval in seconds for how often the device should check the token status | ||||
|  | ||||
| --- | ||||
|  | ||||
| With this response, the device can start checking the status of the token by sending requests to the token endpoint like this: | ||||
|  | ||||
| ``` | ||||
| POST /application/o/token/ HTTP/1.1 | ||||
| Host: authentik.company | ||||
| Content-Type: application/x-www-form-urlencoded | ||||
|  | ||||
| grant_type=urn:ietf:params:oauth:grant-type:device_code& | ||||
| client_id=application_client_id& | ||||
| device_code=device_code_from_above | ||||
| ``` | ||||
|  | ||||
| If the user has not opened the link above yet, or has not finished the authentication and authorization yet, the response will contain an `error` element set to `authorization_pending`. The device should re-send the request in the interval set above. | ||||
|  | ||||
| If the user _has_ finished the authentication and authorization, the response will be similar to any other generic OAuth2 Token request, containing `access_token` and `id_token`. | ||||
| @ -47,7 +47,10 @@ module.exports = { | ||||
|                         type: "doc", | ||||
|                         id: "providers/oauth2/index", | ||||
|                     }, | ||||
|                     items: ["providers/oauth2/client_credentials"], | ||||
|                     items: [ | ||||
|                         "providers/oauth2/client_credentials", | ||||
|                         "providers/oauth2/device_code", | ||||
|                     ], | ||||
|                 }, | ||||
|                 "providers/saml", | ||||
|                 { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L