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.`}
+
+
+
+ ---------
+
+ ${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`
+ ${flow.name} (${flow.slug})
+ `;
+ });
+ }),
+ html`${t`Loading...`} `,
+ )}
+
+
+ ${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`
+ ${this.challenge.flowInfo?.title}
+
+
+ `;
+ }
+}
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`
+ ${this.challenge.flowInfo?.title}
+
+
+ `;
+ }
+}
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",
{