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.mod /work/go.mod
|
||||||
COPY ./go.sum /work/go.sum
|
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
|
# Stage 5: Run
|
||||||
FROM docker.io/python:3.10.7-slim-bullseye AS final-image
|
FROM docker.io/python:3.10.7-slim-bullseye AS final-image
|
||||||
|
|
|
@ -50,6 +50,11 @@ email:
|
||||||
from: authentik@localhost
|
from: authentik@localhost
|
||||||
template_dir: /templates
|
template_dir: /templates
|
||||||
|
|
||||||
|
throttle:
|
||||||
|
providers:
|
||||||
|
oauth2:
|
||||||
|
device: 20/hour
|
||||||
|
|
||||||
outposts:
|
outposts:
|
||||||
# Placeholders:
|
# Placeholders:
|
||||||
# %(type)s: Outpost type; proxy, ldap, etc
|
# %(type)s: Outpost type; proxy, ldap, etc
|
||||||
|
|
|
@ -3,13 +3,20 @@ import string
|
||||||
from random import SystemRandom
|
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"""
|
"""Generate a random client ID"""
|
||||||
rand = SystemRandom()
|
rand = SystemRandom()
|
||||||
return "".join(rand.choice(string.ascii_letters + string.digits) for x in range(length))
|
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"""
|
"""Generate a suitable client secret"""
|
||||||
rand = SystemRandom()
|
rand = SystemRandom()
|
||||||
return "".join(
|
return "".join(
|
||||||
|
|
|
@ -9,6 +9,6 @@ class AuthentikProviderOAuth2Config(AppConfig):
|
||||||
label = "authentik_providers_oauth2"
|
label = "authentik_providers_oauth2"
|
||||||
verbose_name = "authentik Providers.OAuth2"
|
verbose_name = "authentik Providers.OAuth2"
|
||||||
mountpoints = {
|
mountpoints = {
|
||||||
"authentik.providers.oauth2.urls_github": "",
|
"authentik.providers.oauth2.urls_root": "",
|
||||||
"authentik.providers.oauth2.urls": "application/o/",
|
"authentik.providers.oauth2.urls": "application/o/",
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ GRANT_TYPE_IMPLICIT = "implicit"
|
||||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||||
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||||
GRANT_TYPE_PASSWORD = "password" # nosec
|
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_TYPE = "client_assertion_type"
|
||||||
CLIENT_ASSERTION = "client_assertion"
|
CLIENT_ASSERTION = "client_assertion"
|
||||||
|
|
|
@ -235,6 +235,32 @@ class TokenRevocationError(OAuth2Error):
|
||||||
self.description = self.errors[error]
|
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):
|
class BearerTokenError(OAuth2Error):
|
||||||
"""
|
"""
|
||||||
OAuth2 errors.
|
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.crypto.models import CertificateKeyPair
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.utils import get_user
|
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.models import SerializerModel
|
||||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||||
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
||||||
|
@ -320,8 +320,8 @@ class BaseGrantModel(models.Model):
|
||||||
|
|
||||||
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
|
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
|
||||||
user = models.ForeignKey(User, verbose_name=_("User"), 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)
|
revoked = models.BooleanField(default=False)
|
||||||
|
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scope(self) -> list[str]:
|
def scope(self) -> list[str]:
|
||||||
|
@ -516,3 +516,31 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||||
token.claims = claims
|
token.claims = claims
|
||||||
|
|
||||||
return token
|
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 django.views.generic.base import RedirectView
|
||||||
|
|
||||||
from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
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.introspection import TokenIntrospectionView
|
||||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||||
|
@ -17,6 +18,7 @@ urlpatterns = [
|
||||||
name="authorize",
|
name="authorize",
|
||||||
),
|
),
|
||||||
path("token/", TokenView.as_view(), name="token"),
|
path("token/", TokenView.as_view(), name="token"),
|
||||||
|
path("device/", DeviceView.as_view(), name="device"),
|
||||||
path(
|
path(
|
||||||
"userinfo/",
|
"userinfo/",
|
||||||
UserInfoView.as_view(),
|
UserInfoView.as_view(),
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
"""authentik oauth_provider urls"""
|
"""authentik oauth_provider urls"""
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
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.github import GitHubUserTeamsView, GitHubUserView
|
||||||
from authentik.providers.oauth2.views.token import TokenView
|
from authentik.providers.oauth2.views.token import TokenView
|
||||||
|
|
||||||
|
@ -30,4 +32,11 @@ github_urlpatterns = [
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(github_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
|
self.request.session[SESSION_KEY_NEEDS_LOGIN] = True
|
||||||
return self.handle_no_permission()
|
return self.handle_no_permission()
|
||||||
|
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
|
||||||
# Regardless, we start the planner and return to it
|
# Regardless, we start the planner and return to it
|
||||||
planner = FlowPlanner(self.provider.authorization_flow)
|
planner = FlowPlanner(self.provider.authorization_flow)
|
||||||
# planner.use_cache = False
|
|
||||||
planner.allow_empty_flows = True
|
planner.allow_empty_flows = True
|
||||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
|
|
||||||
plan = planner.plan(
|
plan = planner.plan(
|
||||||
self.request,
|
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,
|
ACR_AUTHENTIK_DEFAULT,
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
GRANT_TYPE_DEVICE_CODE,
|
||||||
GRANT_TYPE_IMPLICIT,
|
GRANT_TYPE_IMPLICIT,
|
||||||
GRANT_TYPE_PASSWORD,
|
GRANT_TYPE_PASSWORD,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
GRANT_TYPE_REFRESH_TOKEN,
|
||||||
|
@ -61,6 +62,9 @@ class ProviderInfoView(View):
|
||||||
"revocation_endpoint": self.request.build_absolute_uri(
|
"revocation_endpoint": self.request.build_absolute_uri(
|
||||||
reverse("authentik_providers_oauth2:token-revoke")
|
reverse("authentik_providers_oauth2:token-revoke")
|
||||||
),
|
),
|
||||||
|
"device_authorization_endpoint": self.request.build_absolute_uri(
|
||||||
|
reverse("authentik_providers_oauth2:device")
|
||||||
|
),
|
||||||
"response_types_supported": [
|
"response_types_supported": [
|
||||||
ResponseTypes.CODE,
|
ResponseTypes.CODE,
|
||||||
ResponseTypes.ID_TOKEN,
|
ResponseTypes.ID_TOKEN,
|
||||||
|
@ -81,6 +85,7 @@ class ProviderInfoView(View):
|
||||||
GRANT_TYPE_IMPLICIT,
|
GRANT_TYPE_IMPLICIT,
|
||||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
GRANT_TYPE_PASSWORD,
|
GRANT_TYPE_PASSWORD,
|
||||||
|
GRANT_TYPE_DEVICE_CODE,
|
||||||
],
|
],
|
||||||
"id_token_signing_alg_values_supported": [supported_alg],
|
"id_token_signing_alg_values_supported": [supported_alg],
|
||||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
# 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,
|
CLIENT_ASSERTION_TYPE_JWT,
|
||||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
GRANT_TYPE_DEVICE_CODE,
|
||||||
GRANT_TYPE_PASSWORD,
|
GRANT_TYPE_PASSWORD,
|
||||||
GRANT_TYPE_REFRESH_TOKEN,
|
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 (
|
from authentik.providers.oauth2.models import (
|
||||||
AuthorizationCode,
|
AuthorizationCode,
|
||||||
ClientTypes,
|
ClientTypes,
|
||||||
|
DeviceToken,
|
||||||
OAuth2Provider,
|
OAuth2Provider,
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
)
|
)
|
||||||
|
@ -64,6 +66,7 @@ class TokenParams:
|
||||||
|
|
||||||
authorization_code: Optional[AuthorizationCode] = None
|
authorization_code: Optional[AuthorizationCode] = None
|
||||||
refresh_token: Optional[RefreshToken] = None
|
refresh_token: Optional[RefreshToken] = None
|
||||||
|
device_code: Optional[DeviceToken] = None
|
||||||
user: Optional[User] = None
|
user: Optional[User] = None
|
||||||
|
|
||||||
code_verifier: Optional[str] = None
|
code_verifier: Optional[str] = None
|
||||||
|
@ -139,6 +142,11 @@ class TokenParams:
|
||||||
op="authentik.providers.oauth2.post.parse.client_credentials",
|
op="authentik.providers.oauth2.post.parse.client_credentials",
|
||||||
):
|
):
|
||||||
self.__post_init_client_credentials(request)
|
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:
|
else:
|
||||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
raise TokenError("unsupported_grant_type")
|
raise TokenError("unsupported_grant_type")
|
||||||
|
@ -347,6 +355,13 @@ class TokenParams:
|
||||||
PLAN_CONTEXT_APPLICATION=app,
|
PLAN_CONTEXT_APPLICATION=app,
|
||||||
).from_http(request, user=self.user)
|
).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):
|
def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource):
|
||||||
"""Create user from JWT"""
|
"""Create user from JWT"""
|
||||||
exp = token.get("exp")
|
exp = token.get("exp")
|
||||||
|
@ -413,8 +428,11 @@ class TokenView(View):
|
||||||
if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||||
LOGGER.debug("Client credentials grant")
|
LOGGER.debug("Client credentials grant")
|
||||||
return TokenResponse(self.create_client_credentials_response())
|
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}")
|
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)
|
return TokenResponse(error.create_dict(), status=400)
|
||||||
except UserAuthError as error:
|
except UserAuthError as error:
|
||||||
return TokenResponse(error.create_dict(), status=403)
|
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()),
|
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()),
|
||||||
"id_token": self.provider.encode(refresh_token.id_token.to_dict()),
|
"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."""
|
"""Special challenge for apple-native authentication flow, which happens on the client."""
|
||||||
|
|
||||||
client_id = CharField()
|
client_id = CharField()
|
||||||
component = CharField(default="ak-flow-sources-oauth-apple")
|
component = CharField(default="ak-source-oauth-apple")
|
||||||
scope = CharField()
|
scope = CharField()
|
||||||
redirect_uri = CharField()
|
redirect_uri = CharField()
|
||||||
state = CharField()
|
state = CharField()
|
||||||
|
@ -31,7 +31,7 @@ class AppleLoginChallenge(Challenge):
|
||||||
class AppleChallengeResponse(ChallengeResponse):
|
class AppleChallengeResponse(ChallengeResponse):
|
||||||
"""Pseudo class for plex response"""
|
"""Pseudo class for plex response"""
|
||||||
|
|
||||||
component = CharField(default="ak-flow-sources-oauth-apple")
|
component = CharField(default="ak-source-oauth-apple")
|
||||||
|
|
||||||
|
|
||||||
class AppleOAuthClient(OAuth2Client):
|
class AppleOAuthClient(OAuth2Client):
|
||||||
|
|
|
@ -20,13 +20,13 @@ class PlexAuthenticationChallenge(Challenge):
|
||||||
|
|
||||||
client_id = CharField()
|
client_id = CharField()
|
||||||
slug = CharField()
|
slug = CharField()
|
||||||
component = CharField(default="ak-flow-sources-plex")
|
component = CharField(default="ak-source-plex")
|
||||||
|
|
||||||
|
|
||||||
class PlexAuthenticationChallengeResponse(ChallengeResponse):
|
class PlexAuthenticationChallengeResponse(ChallengeResponse):
|
||||||
"""Pseudo class for plex response"""
|
"""Pseudo class for plex response"""
|
||||||
|
|
||||||
component = CharField(default="ak-flow-sources-plex")
|
component = CharField(default="ak-source-plex")
|
||||||
|
|
||||||
|
|
||||||
class PlexSource(Source):
|
class PlexSource(Source):
|
||||||
|
@ -68,7 +68,7 @@ class PlexSource(Source):
|
||||||
challenge=PlexAuthenticationChallenge(
|
challenge=PlexAuthenticationChallenge(
|
||||||
{
|
{
|
||||||
"type": ChallengeTypes.NATIVE.value,
|
"type": ChallengeTypes.NATIVE.value,
|
||||||
"component": "ak-flow-sources-plex",
|
"component": "ak-source-plex",
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
"slug": self.slug,
|
"slug": self.slug,
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,7 +154,7 @@ class PasswordStageView(ChallengeStageView):
|
||||||
else:
|
else:
|
||||||
if not user:
|
if not user:
|
||||||
# No user was found -> invalid credentials
|
# No user was found -> invalid credentials
|
||||||
self.logger.debug("Invalid credentials")
|
self.logger.info("Invalid credentials")
|
||||||
# Manually inject error into form
|
# Manually inject error into form
|
||||||
response._errors.setdefault("password", [])
|
response._errors.setdefault("password", [])
|
||||||
response._errors["password"].append(ErrorDetail(_("Invalid password"), "invalid"))
|
response._errors["password"].append(ErrorDetail(_("Invalid password"), "invalid"))
|
||||||
|
|
|
@ -51,6 +51,7 @@ class TenantSerializer(ModelSerializer):
|
||||||
"flow_recovery",
|
"flow_recovery",
|
||||||
"flow_unenrollment",
|
"flow_unenrollment",
|
||||||
"flow_user_settings",
|
"flow_user_settings",
|
||||||
|
"flow_device_code",
|
||||||
"event_retention",
|
"event_retention",
|
||||||
"web_certificate",
|
"web_certificate",
|
||||||
"attributes",
|
"attributes",
|
||||||
|
@ -75,6 +76,7 @@ class CurrentTenantSerializer(PassiveSerializer):
|
||||||
flow_recovery = CharField(source="flow_recovery.slug", required=False)
|
flow_recovery = CharField(source="flow_recovery.slug", required=False)
|
||||||
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
|
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
|
||||||
flow_user_settings = CharField(source="flow_user_settings.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)
|
default_locale = CharField(read_only=True)
|
||||||
|
|
||||||
|
@ -101,6 +103,7 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
|
||||||
"flow_recovery",
|
"flow_recovery",
|
||||||
"flow_unenrollment",
|
"flow_unenrollment",
|
||||||
"flow_user_settings",
|
"flow_user_settings",
|
||||||
|
"flow_device_code",
|
||||||
"event_retention",
|
"event_retention",
|
||||||
"web_certificate",
|
"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_user_settings = models.ForeignKey(
|
||||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings"
|
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(
|
event_retention = models.TextField(
|
||||||
default="days=365",
|
default="days=365",
|
||||||
|
|
105
schema.yml
105
schema.yml
|
@ -3529,6 +3529,11 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: flow_device_code
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
- in: query
|
- in: query
|
||||||
name: flow_invalidation
|
name: flow_invalidation
|
||||||
schema:
|
schema:
|
||||||
|
@ -24616,7 +24621,7 @@ components:
|
||||||
component:
|
component:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
default: ak-flow-sources-oauth-apple
|
default: ak-source-oauth-apple
|
||||||
AppleLoginChallenge:
|
AppleLoginChallenge:
|
||||||
type: object
|
type: object
|
||||||
description: Special challenge for apple-native authentication flow, which happens
|
description: Special challenge for apple-native authentication flow, which happens
|
||||||
|
@ -24628,7 +24633,7 @@ components:
|
||||||
$ref: '#/components/schemas/ContextualFlowInfo'
|
$ref: '#/components/schemas/ContextualFlowInfo'
|
||||||
component:
|
component:
|
||||||
type: string
|
type: string
|
||||||
default: ak-flow-sources-oauth-apple
|
default: ak-source-oauth-apple
|
||||||
response_errors:
|
response_errors:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
@ -26028,6 +26033,8 @@ components:
|
||||||
- $ref: '#/components/schemas/EmailChallenge'
|
- $ref: '#/components/schemas/EmailChallenge'
|
||||||
- $ref: '#/components/schemas/FlowErrorChallenge'
|
- $ref: '#/components/schemas/FlowErrorChallenge'
|
||||||
- $ref: '#/components/schemas/IdentificationChallenge'
|
- $ref: '#/components/schemas/IdentificationChallenge'
|
||||||
|
- $ref: '#/components/schemas/OAuthDeviceCodeChallenge'
|
||||||
|
- $ref: '#/components/schemas/OAuthDeviceCodeFinishChallenge'
|
||||||
- $ref: '#/components/schemas/PasswordChallenge'
|
- $ref: '#/components/schemas/PasswordChallenge'
|
||||||
- $ref: '#/components/schemas/PlexAuthenticationChallenge'
|
- $ref: '#/components/schemas/PlexAuthenticationChallenge'
|
||||||
- $ref: '#/components/schemas/PromptChallenge'
|
- $ref: '#/components/schemas/PromptChallenge'
|
||||||
|
@ -26037,7 +26044,7 @@ components:
|
||||||
propertyName: component
|
propertyName: component
|
||||||
mapping:
|
mapping:
|
||||||
ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge'
|
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-duo: '#/components/schemas/AuthenticatorDuoChallenge'
|
||||||
ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge'
|
ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge'
|
||||||
ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge'
|
ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge'
|
||||||
|
@ -26051,8 +26058,10 @@ components:
|
||||||
ak-stage-email: '#/components/schemas/EmailChallenge'
|
ak-stage-email: '#/components/schemas/EmailChallenge'
|
||||||
xak-flow-error: '#/components/schemas/FlowErrorChallenge'
|
xak-flow-error: '#/components/schemas/FlowErrorChallenge'
|
||||||
ak-stage-identification: '#/components/schemas/IdentificationChallenge'
|
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-stage-password: '#/components/schemas/PasswordChallenge'
|
||||||
ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallenge'
|
ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge'
|
||||||
ak-stage-prompt: '#/components/schemas/PromptChallenge'
|
ak-stage-prompt: '#/components/schemas/PromptChallenge'
|
||||||
xak-flow-redirect: '#/components/schemas/RedirectChallenge'
|
xak-flow-redirect: '#/components/schemas/RedirectChallenge'
|
||||||
xak-flow-shell: '#/components/schemas/ShellChallenge'
|
xak-flow-shell: '#/components/schemas/ShellChallenge'
|
||||||
|
@ -26261,6 +26270,8 @@ components:
|
||||||
type: string
|
type: string
|
||||||
flow_user_settings:
|
flow_user_settings:
|
||||||
type: string
|
type: string
|
||||||
|
flow_device_code:
|
||||||
|
type: string
|
||||||
default_locale:
|
default_locale:
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
@ -27240,13 +27251,15 @@ components:
|
||||||
- $ref: '#/components/schemas/DummyChallengeResponseRequest'
|
- $ref: '#/components/schemas/DummyChallengeResponseRequest'
|
||||||
- $ref: '#/components/schemas/EmailChallengeResponseRequest'
|
- $ref: '#/components/schemas/EmailChallengeResponseRequest'
|
||||||
- $ref: '#/components/schemas/IdentificationChallengeResponseRequest'
|
- $ref: '#/components/schemas/IdentificationChallengeResponseRequest'
|
||||||
|
- $ref: '#/components/schemas/OAuthDeviceCodeChallengeResponseRequest'
|
||||||
|
- $ref: '#/components/schemas/OAuthDeviceCodeFinishChallengeResponseRequest'
|
||||||
- $ref: '#/components/schemas/PasswordChallengeResponseRequest'
|
- $ref: '#/components/schemas/PasswordChallengeResponseRequest'
|
||||||
- $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
|
- $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
|
||||||
- $ref: '#/components/schemas/PromptChallengeResponseRequest'
|
- $ref: '#/components/schemas/PromptChallengeResponseRequest'
|
||||||
discriminator:
|
discriminator:
|
||||||
propertyName: component
|
propertyName: component
|
||||||
mapping:
|
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-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest'
|
||||||
ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest'
|
ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest'
|
||||||
ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
|
ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
|
||||||
|
@ -27259,8 +27272,10 @@ components:
|
||||||
ak-stage-dummy: '#/components/schemas/DummyChallengeResponseRequest'
|
ak-stage-dummy: '#/components/schemas/DummyChallengeResponseRequest'
|
||||||
ak-stage-email: '#/components/schemas/EmailChallengeResponseRequest'
|
ak-stage-email: '#/components/schemas/EmailChallengeResponseRequest'
|
||||||
ak-stage-identification: '#/components/schemas/IdentificationChallengeResponseRequest'
|
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-stage-password: '#/components/schemas/PasswordChallengeResponseRequest'
|
||||||
ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
|
ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
|
||||||
ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest'
|
ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest'
|
||||||
FlowDesignationEnum:
|
FlowDesignationEnum:
|
||||||
enum:
|
enum:
|
||||||
|
@ -28632,8 +28647,8 @@ components:
|
||||||
propertyName: component
|
propertyName: component
|
||||||
mapping:
|
mapping:
|
||||||
xak-flow-redirect: '#/components/schemas/RedirectChallenge'
|
xak-flow-redirect: '#/components/schemas/RedirectChallenge'
|
||||||
ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallenge'
|
ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge'
|
||||||
ak-flow-sources-oauth-apple: '#/components/schemas/AppleLoginChallenge'
|
ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge'
|
||||||
LoginMetrics:
|
LoginMetrics:
|
||||||
type: object
|
type: object
|
||||||
description: Login Metrics per 1h
|
description: Login Metrics per 1h
|
||||||
|
@ -29096,6 +29111,64 @@ components:
|
||||||
- provider_info
|
- provider_info
|
||||||
- token
|
- token
|
||||||
- user_info
|
- 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:
|
OAuthSource:
|
||||||
type: object
|
type: object
|
||||||
description: OAuth Source Serializer
|
description: OAuth Source Serializer
|
||||||
|
@ -34205,6 +34278,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
|
flow_device_code:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
event_retention:
|
event_retention:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
@ -34384,7 +34461,7 @@ components:
|
||||||
$ref: '#/components/schemas/ContextualFlowInfo'
|
$ref: '#/components/schemas/ContextualFlowInfo'
|
||||||
component:
|
component:
|
||||||
type: string
|
type: string
|
||||||
default: ak-flow-sources-plex
|
default: ak-source-plex
|
||||||
response_errors:
|
response_errors:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
@ -34406,7 +34483,7 @@ components:
|
||||||
component:
|
component:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
default: ak-flow-sources-plex
|
default: ak-source-plex
|
||||||
PlexSource:
|
PlexSource:
|
||||||
type: object
|
type: object
|
||||||
description: Plex Source Serializer
|
description: Plex Source Serializer
|
||||||
|
@ -36703,6 +36780,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
|
flow_device_code:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
event_retention:
|
event_retention:
|
||||||
type: string
|
type: string
|
||||||
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
|
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
|
||||||
|
@ -36757,6 +36838,10 @@ components:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
|
flow_device_code:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
event_retention:
|
event_retention:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
|
|
@ -46,12 +46,12 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||||
"GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret,
|
"GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret,
|
||||||
"GF_AUTH_GITHUB_SCOPES": "user:email,read:org",
|
"GF_AUTH_GITHUB_SCOPES": "user:email,read:org",
|
||||||
"GF_AUTH_GITHUB_AUTH_URL": self.url(
|
"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(
|
"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",
|
"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.`}
|
${t`If set, users are able to configure details of their profile.`}
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>
|
</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>
|
</div>
|
||||||
</ak-form-group>
|
</ak-form-group>
|
||||||
<ak-form-group>
|
<ak-form-group>
|
||||||
|
|
|
@ -357,18 +357,32 @@ export class FlowExecutor extends AKElement implements StageHost {
|
||||||
.host=${this as StageHost}
|
.host=${this as StageHost}
|
||||||
.challenge=${this.challenge}
|
.challenge=${this.challenge}
|
||||||
></ak-stage-authenticator-validate>`;
|
></ak-stage-authenticator-validate>`;
|
||||||
case "ak-flow-sources-plex":
|
// Sources
|
||||||
|
case "ak-source-plex":
|
||||||
await import("@goauthentik/flow/sources/plex/PlexLoginInit");
|
await import("@goauthentik/flow/sources/plex/PlexLoginInit");
|
||||||
return html`<ak-flow-sources-plex
|
return html`<ak-flow-source-plex
|
||||||
.host=${this as StageHost}
|
.host=${this as StageHost}
|
||||||
.challenge=${this.challenge}
|
.challenge=${this.challenge}
|
||||||
></ak-flow-sources-plex>`;
|
></ak-flow-source-plex>`;
|
||||||
case "ak-flow-sources-oauth-apple":
|
case "ak-source-oauth-apple":
|
||||||
await import("@goauthentik/flow/sources/apple/AppleLoginInit");
|
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}
|
.host=${this as StageHost}
|
||||||
.challenge=${this.challenge}
|
.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:
|
default:
|
||||||
break;
|
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";
|
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> {
|
export class AppleLoginInit extends BaseStage<AppleLoginChallenge, AppleChallengeResponseRequest> {
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
isModalShown = false;
|
isModalShown = false;
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
import { SourcesApi } from "@goauthentik/api";
|
import { SourcesApi } from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-flow-sources-plex")
|
@customElement("ak-flow-source-plex")
|
||||||
export class PlexLoginInit extends BaseStage<
|
export class PlexLoginInit extends BaseStage<
|
||||||
PlexAuthenticationChallenge,
|
PlexAuthenticationChallenge,
|
||||||
PlexAuthenticationChallengeResponseRequest
|
PlexAuthenticationChallengeResponseRequest
|
||||||
|
|
|
@ -112,10 +112,10 @@ export class LibraryPage extends AKElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getApps(): [string, Application[]][] {
|
filterApps(): Application[] {
|
||||||
return groupBy(
|
return (
|
||||||
this.apps?.results.filter((app) => {
|
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 the launch URL is a full URL, only show with http or https
|
||||||
if (app.launchUrl.indexOf("://") !== -1) {
|
if (app.launchUrl.indexOf("://") !== -1) {
|
||||||
return app.launchUrl.startsWith("http");
|
return app.launchUrl.startsWith("http");
|
||||||
|
@ -124,11 +124,14 @@ export class LibraryPage extends AKElement {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) || [],
|
}) || []
|
||||||
(app) => app.group || "",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApps(): [string, Application[]][] {
|
||||||
|
return groupBy(this.filterApps(), (app) => app.group || "");
|
||||||
|
}
|
||||||
|
|
||||||
renderApps(config: UIConfig): TemplateResult {
|
renderApps(config: UIConfig): TemplateResult {
|
||||||
let groupClass = "";
|
let groupClass = "";
|
||||||
let groupGrid = "";
|
let groupGrid = "";
|
||||||
|
@ -215,9 +218,7 @@ export class LibraryPage extends AKElement {
|
||||||
<section class="pf-c-page__main-section">
|
<section class="pf-c-page__main-section">
|
||||||
${loading(
|
${loading(
|
||||||
this.apps,
|
this.apps,
|
||||||
html`${(this.apps?.results || []).filter((app) => {
|
html`${this.filterApps().length > 0
|
||||||
return app.launchUrl !== null;
|
|
||||||
}).length > 0
|
|
||||||
? this.renderApps(config)
|
? this.renderApps(config)
|
||||||
: this.renderEmptyState()}`,
|
: 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",
|
type: "doc",
|
||||||
id: "providers/oauth2/index",
|
id: "providers/oauth2/index",
|
||||||
},
|
},
|
||||||
items: ["providers/oauth2/client_credentials"],
|
items: [
|
||||||
|
"providers/oauth2/client_credentials",
|
||||||
|
"providers/oauth2/device_code",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"providers/saml",
|
"providers/saml",
|
||||||
{
|
{
|
||||||
|
|
Reference in New Issue