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:
Jens L 2022-10-11 13:42:10 +03:00 committed by GitHub
parent 64a7e35950
commit 8ed2f7fe9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1084 additions and 46 deletions

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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/",
}

View File

@ -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"

View File

@ -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.

View File

@ -0,0 +1,61 @@
# Generated by Django 4.0.6 on 2022-07-27 08:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import authentik.core.models
import authentik.lib.generators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("authentik_providers_oauth2", "0012_remove_oauth2provider_verification_keys"),
]
operations = [
migrations.CreateModel(
name="DeviceToken",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"expires",
models.DateTimeField(default=authentik.core.models.default_token_duration),
),
("expiring", models.BooleanField(default=True)),
("device_code", models.TextField(default=authentik.lib.generators.generate_key)),
(
"user_code",
models.TextField(default=authentik.lib.generators.generate_code_fixed_length),
),
("_scope", models.TextField(default="", verbose_name="Scopes")),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_oauth2.oauth2provider",
),
),
(
"user",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Device Token",
"verbose_name_plural": "Device Tokens",
},
),
]

View File

@ -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}"

View File

@ -0,0 +1,62 @@
"""Device backchannel tests"""
from json import loads
from django.urls import reverse
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
class TesOAuth2DeviceBackchannel(OAuthTestCase):
"""Test device back channel"""
def setUp(self) -> None:
self.provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
)
self.application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
def test_backchannel_invalid(self):
"""Test backchannel"""
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
data={
"client_id": "foo",
},
)
self.assertEqual(res.status_code, 400)
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
)
self.assertEqual(res.status_code, 400)
# test without application
self.application.provider = None
self.application.save()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
data={
"client_id": "test",
},
)
self.assertEqual(res.status_code, 400)
def test_backchannel(self):
"""Test backchannel"""
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
data={
"client_id": self.provider.client_id,
},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)

View File

@ -0,0 +1,78 @@
"""Device init tests"""
from urllib.parse import urlencode
from django.urls import reverse
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
class TesOAuth2DeviceInit(OAuthTestCase):
"""Test device init"""
def setUp(self) -> None:
self.provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
)
self.application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
self.user = create_test_admin_user()
self.client.force_login(self.user)
self.device_flow = create_test_flow()
self.tenant = create_test_tenant()
self.tenant.flow_device_code = self.device_flow
self.tenant.save()
def test_device_init(self):
"""Test device init"""
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 302)
self.assertEqual(
res.url,
reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.device_flow.slug,
},
),
)
def test_no_flow(self):
"""Test no flow"""
self.tenant.flow_device_code = None
self.tenant.save()
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 404)
def test_device_init_qs(self):
"""Test device init"""
token = DeviceToken.objects.create(
user_code="foo",
provider=self.provider,
)
res = self.client.get(
reverse("authentik_providers_oauth2_root:device-login")
+ "?"
+ urlencode({QS_KEY_CODE: token.user_code})
)
self.assertEqual(res.status_code, 302)
self.assertEqual(
res.url,
reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.provider.authorization_flow.slug,
},
)
+ "?"
+ urlencode({QS_KEY_CODE: token.user_code}),
)

View File

@ -0,0 +1,83 @@
"""Test token view"""
from json import loads
from django.test import RequestFactory
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
class TestTokenDeviceCode(OAuthTestCase):
"""Test token (device code) view"""
@apply_blueprint("system/providers-oauth2.yaml")
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.provider = OAuth2Provider.objects.create(
name="test",
client_id=generate_id(),
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris="http://testserver",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
self.user = create_test_admin_user()
def test_code_no_code(self):
"""Test code without code"""
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
},
)
self.assertEqual(res.status_code, 400)
body = loads(res.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_code_no_user(self):
"""Test code without user"""
device_token = DeviceToken.objects.create(
provider=self.provider,
user_code=generate_code_fixed_length(),
device_code=generate_id(),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
)
self.assertEqual(res.status_code, 400)
body = loads(res.content.decode())
self.assertEqual(body["error"], "authorization_pending")
def test_code(self):
"""Test code with user"""
device_token = DeviceToken.objects.create(
provider=self.provider,
user_code=generate_code_fixed_length(),
device_code=generate_id(),
user=self.user,
)
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
)
self.assertEqual(res.status_code, 200)

View File

@ -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(),

View File

@ -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",
),
]

View File

@ -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,
{

View File

@ -0,0 +1,82 @@
"""Device flow views"""
from typing import Optional
from urllib.parse import urlencode
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from rest_framework.throttling import AnonRateThrottle
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application
LOGGER = get_logger()
@method_decorator(csrf_exempt, name="dispatch")
class DeviceView(View):
"""Device flow, devices can request tokens which users can verify"""
client_id: str
provider: OAuth2Provider
scopes: list[str] = []
def parse_request(self) -> Optional[HttpResponse]:
"""Parse incoming request"""
client_id = self.request.POST.get("client_id", None)
if not client_id:
return HttpResponseBadRequest()
provider = OAuth2Provider.objects.filter(
client_id=client_id,
).first()
if not provider:
return HttpResponseBadRequest()
if not get_application(provider):
return HttpResponseBadRequest()
self.provider = provider
self.client_id = client_id
self.scopes = self.request.POST.get("scope", "").split(" ")
return None
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
throttle = AnonRateThrottle()
throttle.rate = CONFIG.y("throttle.providers.oauth2.device", "20/hour")
throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate)
if not throttle.allow_request(request, self):
return HttpResponse(status=429)
return super().dispatch(request, *args, **kwargs)
def post(self, request: HttpRequest) -> HttpResponse:
"""Generate device token"""
resp = self.parse_request()
if resp:
return resp
until = timedelta_from_string(self.provider.access_code_validity)
token: DeviceToken = DeviceToken.objects.create(
expires=now() + until, provider=self.provider, _scope=" ".join(self.scopes)
)
device_url = self.request.build_absolute_uri(
reverse("authentik_providers_oauth2_root:device-login")
)
return JsonResponse(
{
"device_code": token.device_code,
"verification_uri": device_url,
"verification_uri_complete": device_url
+ "?"
+ urlencode(
{
QS_KEY_CODE: token.user_code,
}
),
"user_code": token.user_code,
"expires_in": until.total_seconds(),
"interval": 5,
}
)

View File

@ -0,0 +1,46 @@
"""Device flow finish stage"""
from django.http import HttpResponse
from rest_framework.fields import CharField
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import FlowPlan
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.providers.oauth2.models import DeviceToken
PLAN_CONTEXT_DEVICE = "device"
class OAuthDeviceCodeFinishChallenge(Challenge):
"""Final challenge after user enters their code"""
component = CharField(default="ak-provider-oauth2-device-code-finish")
class OAuthDeviceCodeFinishChallengeResponse(ChallengeResponse):
"""Response that device has been authenticated and tab can be closed"""
component = CharField(default="ak-provider-oauth2-device-code-finish")
class OAuthDeviceCodeFinishStage(ChallengeStageView):
"""Stage show at the end of a device flow"""
response_class = OAuthDeviceCodeFinishChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
token: DeviceToken = plan.context[PLAN_CONTEXT_DEVICE]
# As we're required to be authenticated by now, we can rely on
# request.user
token.user = self.request.user
token.save()
return OAuthDeviceCodeFinishChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
"component": "ak-provider-oauth2-device-code-finish",
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
self.executor.stage_ok()

View File

@ -0,0 +1,146 @@
"""Device flow views"""
from typing import Optional
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.views import View
from rest_framework.exceptions import ErrorDetail
from rest_framework.fields import CharField, IntegerField
from structlog.stdlib import get_logger
from authentik.core.models import Application
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.views.device_finish import (
PLAN_CONTEXT_DEVICE,
OAuthDeviceCodeFinishStage,
)
from authentik.providers.oauth2.views.userinfo import UserInfoView
from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
)
from authentik.tenants.models import Tenant
LOGGER = get_logger()
QS_KEY_CODE = "code" # nosec
def get_application(provider: OAuth2Provider) -> Optional[Application]:
"""Get application from provider"""
try:
app = provider.application
if not app:
return None
return app
except Application.DoesNotExist:
return None
def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
"""Validate user token"""
token = DeviceToken.objects.filter(
user_code=code,
).first()
if not token:
return None
app = get_application(token.provider)
if not app:
return None
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope)
planner = FlowPlanner(token.provider.authorization_flow)
planner.allow_empty_flows = True
plan = planner.plan(
request,
{
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: app,
# OAuth2 related params
PLAN_CONTEXT_DEVICE: token,
# Consent related params
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
% {"application": app.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
},
)
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
flow_slug=token.provider.authorization_flow.slug,
)
class DeviceEntryView(View):
"""View used to initiate the device-code flow, url entered by endusers"""
def dispatch(self, request: HttpRequest) -> HttpResponse:
tenant: Tenant = request.tenant
device_flow = tenant.flow_device_code
if not device_flow:
LOGGER.info("Tenant has no device code flow configured", tenant=tenant)
return HttpResponse(status=404)
if QS_KEY_CODE in request.GET:
validation = validate_code(request.GET[QS_KEY_CODE], request)
if validation:
return validation
LOGGER.info("Got code from query parameter but no matching token found")
# Regardless, we start the planner and return to it
planner = FlowPlanner(device_flow)
planner.allow_empty_flows = True
plan = planner.plan(self.request)
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=device_flow.slug,
)
class OAuthDeviceCodeChallenge(Challenge):
"""OAuth Device code challenge"""
component = CharField(default="ak-provider-oauth2-device-code")
class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
"""Response that includes the user-entered device code"""
code = IntegerField()
component = CharField(default="ak-provider-oauth2-device-code")
class OAuthDeviceCodeStage(ChallengeStageView):
"""Flow challenge for users to enter device codes"""
response_class = OAuthDeviceCodeChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
return OAuthDeviceCodeChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
"component": "ak-provider-oauth2-device-code",
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
code = response.validated_data["code"]
validation = validate_code(code, self.request)
if not validation:
response._errors.setdefault("code", [])
response._errors["code"].append(ErrorDetail(_("Invalid code"), "invalid"))
return self.challenge_invalid(response)
# Run cancel to cleanup the current flow
self.executor.cancel()
return validation

View File

@ -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

View File

@ -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()),
}

View File

@ -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):

View File

@ -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,
}

View File

@ -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"))

View File

@ -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",
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1 on 2022-09-03 21:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0023_flow_denied_action"),
("authentik_tenants", "0003_tenant_attributes"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="flow_device_code",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_device_code",
to="authentik_flows.flow",
),
),
]

View File

@ -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",

View File

@ -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

View File

@ -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",
},
}

View File

@ -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>

View File

@ -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;
}

View File

@ -0,0 +1,80 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
OAuthDeviceCodeChallenge,
OAuthDeviceCodeChallengeResponseRequest,
} from "@goauthentik/api";
@customElement("ak-flow-provider-oauth2-code")
export class OAuth2DeviceCode extends BaseStage<
OAuthDeviceCodeChallenge,
OAuthDeviceCodeChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal];
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<p>${t`Enter the code shown on your device.`}</p>
<ak-form-element
label="${t`Code`}"
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["code"]}
>
<!-- @ts-ignore -->
<input
type="text"
name="code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="${t`Please enter your Code`}"
autofocus=""
autocomplete="off"
class="pf-c-form-control"
value=""
required
/>
</ak-form-element>
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${t`Continue`}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
}

View File

@ -0,0 +1,55 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
OAuthDeviceCodeFinishChallenge,
OAuthDeviceCodeFinishChallengeResponseRequest,
} from "@goauthentik/api";
@customElement("ak-flow-provider-oauth2-code-finish")
export class DeviceCodeFinish extends BaseStage<
OAuthDeviceCodeFinishChallenge,
OAuthDeviceCodeFinishChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFList, PFFormControl, PFTitle, AKGlobal];
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form">
<div class="pf-c-form__group">
<p>
<i class="pf-icon pf-icon-ok"></i>
${t`You've successfully authenticated your device.`}
</p>
<hr />
<p>${t`You can close this tab now.`}</p>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
}

View File

@ -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;

View File

@ -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

View File

@ -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()}`,
)}

View File

@ -0,0 +1,49 @@
# Device code flow
(Also known as device flow and RFC 8628)
This type of authentication flow is useful for devices with limited input abilities and/or devices without browsers.
### Requirements
This device flow is only possible if the active tenant has a device code flow setup. This device code flow is run _after_ the user logs in, and before the user authenticates.
### Device-side
The flow is initiated by sending a POST request to the device authorization endpoint, `/application/o/device/` with the following contents:
```
POST /application/o/device/ HTTP/1.1
Host: authentik.company
Content-Type: application/x-www-form-urlencoded
client_id=application_client_id&
scopes=openid email my-other-scope
```
The response contains the following fields:
- `device_code`: Device code, which is the code kept on the device
- `verification_uri`: The URL to be shown to the enduser to input the code
- `verification_uri_complete`: The same URL as above except the code will be prefilled
- `user_code`: The raw code for the enduser to input
- `expires_in`: The total seconds after which this token will expire
- `interval`: The interval in seconds for how often the device should check the token status
---
With this response, the device can start checking the status of the token by sending requests to the token endpoint like this:
```
POST /application/o/token/ HTTP/1.1
Host: authentik.company
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code&
client_id=application_client_id&
device_code=device_code_from_above
```
If the user has not opened the link above yet, or has not finished the authentication and authorization yet, the response will contain an `error` element set to `authorization_pending`. The device should re-send the request in the interval set above.
If the user _has_ finished the authentication and authorization, the response will be similar to any other generic OAuth2 Token request, containing `access_token` and `id_token`.

View File

@ -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",
{