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