From 8ed2f7fe9ef1694666b0fe244e67ddc635e57c36 Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 11 Oct 2022 13:42:10 +0300 Subject: [PATCH] providers/oauth2: add device flow (#3334) * start device flow Signed-off-by: Jens Langhammer * web: fix inconsistent app filtering Signed-off-by: Jens Langhammer * add tenant device code flow Signed-off-by: Jens Langhammer * add throttling to device code view Signed-off-by: Jens Langhammer * somewhat unrelated changes Signed-off-by: Jens Langhammer * add initial device code entry flow Signed-off-by: Jens Langhammer * add finish stage Signed-off-by: Jens Langhammer * it works Signed-off-by: Jens Langhammer * add support for verification_uri_complete Signed-off-by: Jens Langhammer * add some tests Signed-off-by: Jens Langhammer * add more tests Signed-off-by: Jens Langhammer * add docs Signed-off-by: Jens Langhammer Signed-off-by: Jens Langhammer --- Dockerfile | 2 +- authentik/lib/default.yml | 5 + authentik/lib/generators.py | 11 +- authentik/providers/oauth2/apps.py | 2 +- authentik/providers/oauth2/constants.py | 1 + authentik/providers/oauth2/errors.py | 26 ++++ .../oauth2/migrations/0013_devicetoken.py | 61 ++++++++ authentik/providers/oauth2/models.py | 32 +++- .../oauth2/tests/test_device_backchannel.py | 62 ++++++++ .../oauth2/tests/test_device_init.py | 78 ++++++++++ .../oauth2/tests/test_token_device.py | 83 ++++++++++ authentik/providers/oauth2/urls.py | 2 + .../oauth2/{urls_github.py => urls_root.py} | 9 ++ authentik/providers/oauth2/views/authorize.py | 3 +- .../oauth2/views/device_backchannel.py | 82 ++++++++++ .../providers/oauth2/views/device_finish.py | 46 ++++++ .../providers/oauth2/views/device_init.py | 146 ++++++++++++++++++ authentik/providers/oauth2/views/provider.py | 5 + authentik/providers/oauth2/views/token.py | 50 +++++- authentik/sources/oauth/types/apple.py | 4 +- authentik/sources/plex/models.py | 6 +- authentik/stages/password/stage.py | 2 +- authentik/tenants/api.py | 3 + .../0004_tenant_flow_device_code.py | 25 +++ authentik/tenants/models.py | 3 + schema.yml | 105 +++++++++++-- tests/e2e/test_provider_oauth2_github.py | 6 +- web/src/admin/tenants/TenantForm.ts | 34 ++++ web/src/flow/FlowExecutor.ts | 26 +++- web/src/flow/providers/oauth2/DeviceCode.ts | 80 ++++++++++ .../flow/providers/oauth2/DeviceCodeFinish.ts | 55 +++++++ web/src/flow/sources/apple/AppleLoginInit.ts | 2 +- web/src/flow/sources/plex/PlexLoginInit.ts | 2 +- web/src/user/LibraryPage.ts | 17 +- website/docs/providers/oauth2/device_code.md | 49 ++++++ website/sidebars.js | 5 +- 36 files changed, 1084 insertions(+), 46 deletions(-) create mode 100644 authentik/providers/oauth2/migrations/0013_devicetoken.py create mode 100644 authentik/providers/oauth2/tests/test_device_backchannel.py create mode 100644 authentik/providers/oauth2/tests/test_device_init.py create mode 100644 authentik/providers/oauth2/tests/test_token_device.py rename authentik/providers/oauth2/{urls_github.py => urls_root.py} (75%) create mode 100644 authentik/providers/oauth2/views/device_backchannel.py create mode 100644 authentik/providers/oauth2/views/device_finish.py create mode 100644 authentik/providers/oauth2/views/device_init.py create mode 100644 authentik/tenants/migrations/0004_tenant_flow_device_code.py create mode 100644 web/src/flow/providers/oauth2/DeviceCode.ts create mode 100644 web/src/flow/providers/oauth2/DeviceCodeFinish.ts create mode 100644 website/docs/providers/oauth2/device_code.md diff --git a/Dockerfile b/Dockerfile index 0fd0d65fe..e1863cec8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 5ef088388..5a2428cd6 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -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 diff --git a/authentik/lib/generators.py b/authentik/lib/generators.py index b0f1303d0..2716cc93e 100644 --- a/authentik/lib/generators.py +++ b/authentik/lib/generators.py @@ -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( diff --git a/authentik/providers/oauth2/apps.py b/authentik/providers/oauth2/apps.py index 437618c94..75a39d764 100644 --- a/authentik/providers/oauth2/apps.py +++ b/authentik/providers/oauth2/apps.py @@ -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/", } diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index 044818317..fb45587cf 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -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" diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 8d1f028fc..5545f34f1 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -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. diff --git a/authentik/providers/oauth2/migrations/0013_devicetoken.py b/authentik/providers/oauth2/migrations/0013_devicetoken.py new file mode 100644 index 000000000..987f2a354 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0013_devicetoken.py @@ -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", + }, + ), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 9f885c808..ea40afd4a 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -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}" diff --git a/authentik/providers/oauth2/tests/test_device_backchannel.py b/authentik/providers/oauth2/tests/test_device_backchannel.py new file mode 100644 index 000000000..a191e128b --- /dev/null +++ b/authentik/providers/oauth2/tests/test_device_backchannel.py @@ -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) diff --git a/authentik/providers/oauth2/tests/test_device_init.py b/authentik/providers/oauth2/tests/test_device_init.py new file mode 100644 index 000000000..4e2a9e2bb --- /dev/null +++ b/authentik/providers/oauth2/tests/test_device_init.py @@ -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}), + ) diff --git a/authentik/providers/oauth2/tests/test_token_device.py b/authentik/providers/oauth2/tests/test_token_device.py new file mode 100644 index 000000000..a9536e0ab --- /dev/null +++ b/authentik/providers/oauth2/tests/test_token_device.py @@ -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) diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py index 5c14f0cad..c25fcfc14 100644 --- a/authentik/providers/oauth2/urls.py +++ b/authentik/providers/oauth2/urls.py @@ -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(), diff --git a/authentik/providers/oauth2/urls_github.py b/authentik/providers/oauth2/urls_root.py similarity index 75% rename from authentik/providers/oauth2/urls_github.py rename to authentik/providers/oauth2/urls_root.py index c349cff5b..b00a90de4 100644 --- a/authentik/providers/oauth2/urls_github.py +++ b/authentik/providers/oauth2/urls_root.py @@ -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", + ), ] diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index e9f85c3a5..552acba50 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -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, { diff --git a/authentik/providers/oauth2/views/device_backchannel.py b/authentik/providers/oauth2/views/device_backchannel.py new file mode 100644 index 000000000..7818701b1 --- /dev/null +++ b/authentik/providers/oauth2/views/device_backchannel.py @@ -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, + } + ) diff --git a/authentik/providers/oauth2/views/device_finish.py b/authentik/providers/oauth2/views/device_finish.py new file mode 100644 index 000000000..e06c5c9bc --- /dev/null +++ b/authentik/providers/oauth2/views/device_finish.py @@ -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() diff --git a/authentik/providers/oauth2/views/device_init.py b/authentik/providers/oauth2/views/device_init.py new file mode 100644 index 000000000..c1be90370 --- /dev/null +++ b/authentik/providers/oauth2/views/device_init.py @@ -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 diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index bd2811541..80080cc40 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -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 diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index b04131455..0a7fdeb61 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -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()), + } diff --git a/authentik/sources/oauth/types/apple.py b/authentik/sources/oauth/types/apple.py index 30d141f97..34c55042e 100644 --- a/authentik/sources/oauth/types/apple.py +++ b/authentik/sources/oauth/types/apple.py @@ -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): diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 4c902cf88..19d232824 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -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, } diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index d78ec2ba2..266c0fde8 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -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")) diff --git a/authentik/tenants/api.py b/authentik/tenants/api.py index 770a74607..0c3a33fec 100644 --- a/authentik/tenants/api.py +++ b/authentik/tenants/api.py @@ -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", ] diff --git a/authentik/tenants/migrations/0004_tenant_flow_device_code.py b/authentik/tenants/migrations/0004_tenant_flow_device_code.py new file mode 100644 index 000000000..01172b38d --- /dev/null +++ b/authentik/tenants/migrations/0004_tenant_flow_device_code.py @@ -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", + ), + ), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index 4f99b0b42..9eb685ca3 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -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", diff --git a/schema.yml b/schema.yml index 47324b0fd..89127066f 100644 --- a/schema.yml +++ b/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 diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index f24797b90..b71e32063 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -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", }, } diff --git a/web/src/admin/tenants/TenantForm.ts b/web/src/admin/tenants/TenantForm.ts index f3aa0dead..f1675509f 100644 --- a/web/src/admin/tenants/TenantForm.ts +++ b/web/src/admin/tenants/TenantForm.ts @@ -314,6 +314,40 @@ export class TenantForm extends ModelForm { ${t`If set, users are able to configure details of their profile.`}

+ + +

+ ${t`If set, the OAuth Device Code profile can be used, and the selected flow will be used to enter the code.`} +

+
diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 9030f683f..e75dfe7ce 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -357,18 +357,32 @@ export class FlowExecutor extends AKElement implements StageHost { .host=${this as StageHost} .challenge=${this.challenge} >`; - case "ak-flow-sources-plex": + // Sources + case "ak-source-plex": await import("@goauthentik/flow/sources/plex/PlexLoginInit"); - return html``; - case "ak-flow-sources-oauth-apple": + >`; + case "ak-source-oauth-apple": await import("@goauthentik/flow/sources/apple/AppleLoginInit"); - return html``; + >`; + // Providers + case "ak-provider-oauth2-device-code": + await import("@goauthentik/flow/providers/oauth2/DeviceCode"); + return html``; + case "ak-provider-oauth2-device-code-finish": + await import("@goauthentik/flow/providers/oauth2/DeviceCodeFinish"); + return html``; default: break; } diff --git a/web/src/flow/providers/oauth2/DeviceCode.ts b/web/src/flow/providers/oauth2/DeviceCode.ts new file mode 100644 index 000000000..f4ca7707e --- /dev/null +++ b/web/src/flow/providers/oauth2/DeviceCode.ts @@ -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` `; + } + return html` + +
+ +
`; + } +} diff --git a/web/src/flow/providers/oauth2/DeviceCodeFinish.ts b/web/src/flow/providers/oauth2/DeviceCodeFinish.ts new file mode 100644 index 000000000..c2f082b13 --- /dev/null +++ b/web/src/flow/providers/oauth2/DeviceCodeFinish.ts @@ -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` `; + } + return html` + +
+ +
`; + } +} diff --git a/web/src/flow/sources/apple/AppleLoginInit.ts b/web/src/flow/sources/apple/AppleLoginInit.ts index f271b4c9b..1dd84cf2d 100644 --- a/web/src/flow/sources/apple/AppleLoginInit.ts +++ b/web/src/flow/sources/apple/AppleLoginInit.ts @@ -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 { @property({ type: Boolean }) isModalShown = false; diff --git a/web/src/flow/sources/plex/PlexLoginInit.ts b/web/src/flow/sources/plex/PlexLoginInit.ts index d81022af0..e375fafb6 100644 --- a/web/src/flow/sources/plex/PlexLoginInit.ts +++ b/web/src/flow/sources/plex/PlexLoginInit.ts @@ -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 diff --git a/web/src/user/LibraryPage.ts b/web/src/user/LibraryPage.ts index 4921fa6e7..05a56d054 100644 --- a/web/src/user/LibraryPage.ts +++ b/web/src/user/LibraryPage.ts @@ -112,10 +112,10 @@ export class LibraryPage extends AKElement { `; } - 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 {
${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()}`, )} diff --git a/website/docs/providers/oauth2/device_code.md b/website/docs/providers/oauth2/device_code.md new file mode 100644 index 000000000..eed7615f4 --- /dev/null +++ b/website/docs/providers/oauth2/device_code.md @@ -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`. diff --git a/website/sidebars.js b/website/sidebars.js index e0b142147..e4d584448 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -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", {