From c5a2831665efe28a5ee60e5431d72d8c472ab8ad Mon Sep 17 00:00:00 2001 From: Jens L Date: Sun, 26 Jun 2022 17:51:15 +0200 Subject: [PATCH] api: add basic jwt support with required scope (#2624) * api: add basic jwt support with required scope Signed-off-by: Jens Langhammer * api: only set auth_via when actually authenticating via token Signed-off-by: Jens Langhammer * save consented permissions in user consent, re-prompt when new permissions are required Signed-off-by: Jens Langhammer * update locale Signed-off-by: Jens Langhammer * translate special scope map Signed-off-by: Jens Langhammer * more api auth tests Signed-off-by: Jens Langhammer * add docs Signed-off-by: Jens Langhammer * build web api in e2e tests Signed-off-by: Jens Langhammer * link generated client instead of copying Signed-off-by: Jens Langhammer --- .github/workflows/ci-main.yml | 2 + Makefile | 9 +- authentik/api/authentication.py | 30 +- authentik/api/tests/test_auth.py | 46 +- authentik/flows/challenge.py | 9 +- authentik/providers/oauth2/constants.py | 2 + authentik/providers/oauth2/views/userinfo.py | 27 +- authentik/stages/consent/api.py | 2 +- ...er_userconsent_unique_together_and_more.py | 29 + authentik/stages/consent/models.py | 3 +- authentik/stages/consent/stage.py | 39 +- authentik/stages/consent/tests.py | 117 +++- locale/en/LC_MESSAGES/django.po | 573 ++++++++++-------- schema.yml | 9 +- web/src/flows/stages/consent/ConsentStage.ts | 80 ++- web/src/locales/de.po | 252 +++++++- web/src/locales/en.po | 252 +++++++- web/src/locales/es.po | 252 +++++++- web/src/locales/fr_FR.po | 252 +++++++- web/src/locales/pl.po | 252 +++++++- web/src/locales/pseudo-LOCALE.po | 252 +++++++- web/src/locales/tr.po | 252 +++++++- web/src/locales/zh-Hans.po | 248 +++++++- web/src/locales/zh-Hant.po | 248 +++++++- web/src/locales/zh_TW.po | 248 +++++++- website/developer-docs/api/api.md | 16 +- 26 files changed, 3064 insertions(+), 437 deletions(-) create mode 100644 authentik/stages/consent/migrations/0004_alter_userconsent_unique_together_and_more.py diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 44a606f47..159d85aa2 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -139,6 +139,7 @@ jobs: working-directory: web run: | npm ci + make -C .. gen-client-web npm run build - name: run e2e run: | @@ -172,6 +173,7 @@ jobs: working-directory: web/ run: | npm ci + make -C .. gen-client-web npm run build - name: run e2e run: | diff --git a/Makefile b/Makefile index 3cb2feaba..734b6e0da 100644 --- a/Makefile +++ b/Makefile @@ -71,9 +71,9 @@ gen-client-web: -o /local/gen-ts-api \ --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION} mkdir -p web/node_modules/@goauthentik/api - \cp -fv scripts/web_api_readme.md gen-ts-api/README.md + ln -fs scripts/web_api_readme.md gen-ts-api/README.md cd gen-ts-api && npm i - \cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api + ln -fs gen-ts-api web/node_modules/@goauthentik/api gen-client-go: wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml @@ -169,8 +169,3 @@ ci-pending-migrations: ci--meta-debug install: web-install website-install poetry install - -a: install - tmux \ - new-session 'make run' \; \ - split-window 'make web-watch' diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index 5e95fab1d..25e1b6a6f 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -10,6 +10,8 @@ from structlog.stdlib import get_logger from authentik.core.middleware import KEY_AUTH_VIA, LOCAL from authentik.core.models import Token, TokenIntents, User from authentik.outposts.models import Outpost +from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API +from authentik.providers.oauth2.models import RefreshToken LOGGER = get_logger() @@ -24,7 +26,7 @@ def validate_auth(header: bytes) -> str: if auth_type.lower() != "bearer": LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) raise AuthenticationFailed("Unsupported authentication type") - if auth_credentials == "": # nosec + if auth_credentials == "": # nosec # noqa raise AuthenticationFailed("Malformed header") return auth_credentials @@ -34,14 +36,30 @@ def bearer_auth(raw_header: bytes) -> Optional[User]: auth_credentials = validate_auth(raw_header) if not auth_credentials: return None + if not hasattr(LOCAL, "authentik"): + LOCAL.authentik = {} # first, check traditional tokens - token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first() - if hasattr(LOCAL, "authentik"): + key_token = Token.filter_not_expired( + key=auth_credentials, intent=TokenIntents.INTENT_API + ).first() + if key_token: LOCAL.authentik[KEY_AUTH_VIA] = "api_token" - if token: - return token.user + return key_token.user + # then try to auth via JWT + jwt_token = RefreshToken.filter_not_expired( + refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API + ).first() + if jwt_token: + # Double-check scopes, since they are saved in a single string + # we want to check the parsed version too + if SCOPE_AUTHENTIK_API not in jwt_token.scope: + raise AuthenticationFailed("Token invalid/expired") + LOCAL.authentik[KEY_AUTH_VIA] = "jwt" + return jwt_token.user + # then try to auth via secret key (for embedded outpost/etc) user = token_secret_key(auth_credentials) if user: + LOCAL.authentik[KEY_AUTH_VIA] = "secret_key" return user raise AuthenticationFailed("Token invalid/expired") @@ -56,8 +74,6 @@ def token_secret_key(value: str) -> Optional[User]: outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) if not outposts: return None - if hasattr(LOCAL, "authentik"): - LOCAL.authentik[KEY_AUTH_VIA] = "secret_key" outpost = outposts.first() return outpost.user diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index ef49d3dee..91b05b3cf 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -8,28 +8,37 @@ from rest_framework.exceptions import AuthenticationFailed from authentik.api.authentication import bearer_auth from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents +from authentik.core.tests.utils import create_test_flow +from authentik.lib.generators import generate_id from authentik.outposts.managed import OutpostManager +from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API +from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken class TestAPIAuth(TestCase): """Test API Authentication""" - def test_valid_bearer(self): - """Test valid token""" - token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user()) - self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) - def test_invalid_type(self): """Test invalid type""" with self.assertRaises(AuthenticationFailed): bearer_auth("foo bar".encode()) + def test_invalid_empty(self): + """Test invalid type""" + self.assertIsNone(bearer_auth("Bearer ".encode())) + self.assertIsNone(bearer_auth("".encode())) + def test_invalid_no_token(self): """Test invalid with no token""" with self.assertRaises(AuthenticationFailed): auth = b64encode(":abc".encode()).decode() self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) + def test_bearer_valid(self): + """Test valid token""" + token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user()) + self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) + def test_managed_outpost(self): """Test managed outpost""" with self.assertRaises(AuthenticationFailed): @@ -38,3 +47,30 @@ class TestAPIAuth(TestCase): OutpostManager().run() user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) + + def test_jwt_valid(self): + """Test valid JWT""" + provider = OAuth2Provider.objects.create( + name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() + ) + refresh = RefreshToken.objects.create( + user=get_anonymous_user(), + provider=provider, + refresh_token=generate_id(), + _scope=SCOPE_AUTHENTIK_API, + ) + self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) + + def test_jwt_missing_scope(self): + """Test valid JWT""" + provider = OAuth2Provider.objects.create( + name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() + ) + refresh = RefreshToken.objects.create( + user=get_anonymous_user(), + provider=provider, + refresh_token=generate_id(), + _scope="", + ) + with self.assertRaises(AuthenticationFailed): + self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index e3fefd4ca..2933f0284 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -1,6 +1,6 @@ """Challenge helpers""" from enum import Enum -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, TypedDict from django.db import models from django.http import JsonResponse @@ -95,6 +95,13 @@ class AccessDeniedChallenge(WithUserInfoChallenge): component = CharField(default="ak-stage-access-denied") +class PermissionDict(TypedDict): + """Consent Permission""" + + id: str + name: str + + class PermissionSerializer(PassiveSerializer): """Permission used for consent""" diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index 7edf5b9c3..044818317 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -18,6 +18,8 @@ SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_EMAIL = "email" +SCOPE_AUTHENTIK_API = "goauthentik.io/api" + # Read/write full user (including email) SCOPE_GITHUB_USER = "user" # Read user (without email) diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py index 36a875029..efed0f43f 100644 --- a/authentik/providers/oauth2/views/userinfo.py +++ b/authentik/providers/oauth2/views/userinfo.py @@ -4,12 +4,15 @@ from typing import Any, Optional from deepmerge import always_merger from django.http import HttpRequest, HttpResponse from django.http.response import HttpResponseBadRequest +from django.utils.translation import gettext_lazy as _ from django.views import View from structlog.stdlib import get_logger from authentik.core.exceptions import PropertyMappingExpressionException from authentik.events.models import Event, EventAction +from authentik.flows.challenge import PermissionDict from authentik.providers.oauth2.constants import ( + SCOPE_AUTHENTIK_API, SCOPE_GITHUB_ORG_READ, SCOPE_GITHUB_USER, SCOPE_GITHUB_USER_EMAIL, @@ -27,23 +30,27 @@ class UserInfoView(View): token: Optional[RefreshToken] - def get_scope_descriptions(self, scopes: list[str]) -> list[dict[str, str]]: + def get_scope_descriptions(self, scopes: list[str]) -> list[PermissionDict]: """Get a list of all Scopes's descriptions""" scope_descriptions = [] for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by("scope_name"): - if scope.description != "": - scope_descriptions.append({"id": scope.scope_name, "name": scope.description}) + if scope.description == "": + continue + scope_descriptions.append(PermissionDict(id=scope.scope_name, name=scope.description)) # GitHub Compatibility Scopes are handled differently, since they required custom paths # Hence they don't exist as Scope objects - github_scope_map = { - SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"), - SCOPE_GITHUB_USER_READ: ("GitHub Compatibility: Access your User Information"), - SCOPE_GITHUB_USER_EMAIL: ("GitHub Compatibility: Access you Email addresses"), - SCOPE_GITHUB_ORG_READ: ("GitHub Compatibility: Access your Groups"), + special_scope_map = { + SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"), + SCOPE_GITHUB_USER_READ: _("GitHub Compatibility: Access your User Information"), + SCOPE_GITHUB_USER_EMAIL: _("GitHub Compatibility: Access you Email addresses"), + SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"), + SCOPE_AUTHENTIK_API: _("authentik API Access on behalf of your user"), } for scope in scopes: - if scope in github_scope_map: - scope_descriptions.append({"id": scope, "name": github_scope_map[scope]}) + if scope in special_scope_map: + scope_descriptions.append( + PermissionDict(id=scope, name=str(special_scope_map[scope])) + ) return scope_descriptions def get_claims(self, token: RefreshToken) -> dict[str, Any]: diff --git a/authentik/stages/consent/api.py b/authentik/stages/consent/api.py index f170020b0..5a25b1db0 100644 --- a/authentik/stages/consent/api.py +++ b/authentik/stages/consent/api.py @@ -40,7 +40,7 @@ class UserConsentSerializer(StageSerializer): class Meta: model = UserConsent - fields = ["pk", "expires", "user", "application"] + fields = ["pk", "expires", "user", "application", "permissions"] class UserConsentViewSet( diff --git a/authentik/stages/consent/migrations/0004_alter_userconsent_unique_together_and_more.py b/authentik/stages/consent/migrations/0004_alter_userconsent_unique_together_and_more.py new file mode 100644 index 000000000..396f1e6c9 --- /dev/null +++ b/authentik/stages/consent/migrations/0004_alter_userconsent_unique_together_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.0.5 on 2022-06-26 10:42 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0021_source_user_path_user_path"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_stages_consent", "0003_auto_20200924_1403"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="userconsent", + unique_together=set(), + ), + migrations.AddField( + model_name="userconsent", + name="permissions", + field=models.TextField(default=""), + ), + migrations.AlterUniqueTogether( + name="userconsent", + unique_together={("user", "application", "permissions")}, + ), + ] diff --git a/authentik/stages/consent/models.py b/authentik/stages/consent/models.py index 45c251cb2..672082147 100644 --- a/authentik/stages/consent/models.py +++ b/authentik/stages/consent/models.py @@ -56,12 +56,13 @@ class UserConsent(ExpiringModel): user = models.ForeignKey(User, on_delete=models.CASCADE) application = models.ForeignKey(Application, on_delete=models.CASCADE) + permissions = models.TextField(default="") def __str__(self): return f"User Consent {self.application} by {self.user}" class Meta: - unique_together = (("user", "application"),) + unique_together = (("user", "application", "permissions"),) verbose_name = _("User Consent") verbose_name_plural = _("User Consents") diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py index bfeff6046..ec515f822 100644 --- a/authentik/stages/consent/stage.py +++ b/authentik/stages/consent/stage.py @@ -1,4 +1,6 @@ """authentik consent stage""" +from typing import Optional + from django.http import HttpRequest, HttpResponse from django.utils.timezone import now from rest_framework.fields import CharField @@ -18,13 +20,15 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConse PLAN_CONTEXT_CONSENT_TITLE = "consent_title" PLAN_CONTEXT_CONSENT_HEADER = "consent_header" PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions" +PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS = "consent_additional_permissions" class ConsentChallenge(WithUserInfoChallenge): """Challenge info for consent screens""" - header_text = CharField() + header_text = CharField(required=False) permissions = PermissionSerializer(many=True) + additional_permissions = PermissionSerializer(many=True) component = CharField(default="ak-stage-consent") @@ -43,6 +47,9 @@ class ConsentStageView(ChallengeStageView): data = { "type": ChallengeTypes.NATIVE.value, "permissions": self.executor.plan.context.get(PLAN_CONTEXT_CONSENT_PERMISSIONS, []), + "additional_permissions": self.executor.plan.context.get( + PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS, [] + ), } if PLAN_CONTEXT_CONSENT_TITLE in self.executor.plan.context: data["title"] = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TITLE] @@ -72,10 +79,26 @@ class ConsentStageView(ChallengeStageView): if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - if UserConsent.filter_not_expired(user=user, application=application).exists(): + consent: Optional[UserConsent] = UserConsent.filter_not_expired( + user=user, application=application + ).first() + + if consent: + perms = self.executor.plan.context.get(PLAN_CONTEXT_CONSENT_PERMISSIONS, []) + allowed_perms = set(consent.permissions.split(" ")) + requested_perms = set(x["id"] for x in perms) + + if allowed_perms != requested_perms: + self.executor.plan.context[PLAN_CONTEXT_CONSENT_PERMISSIONS] = [ + x for x in perms if x["id"] in allowed_perms + ] + self.executor.plan.context[PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS] = [ + x for x in perms if x["id"] in requested_perms.difference(allowed_perms) + ] + return super().get(request, *args, **kwargs) return self.executor.stage_ok() - # No consent found, return consent + # No consent found, return consent prompt return super().get(request, *args, **kwargs) def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: @@ -83,6 +106,10 @@ class ConsentStageView(ChallengeStageView): if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: return self.executor.stage_ok() application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + permissions = self.executor.plan.context.get( + PLAN_CONTEXT_CONSENT_PERMISSIONS, [] + ) + self.executor.plan.context.get(PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS, []) + permissions_string = " ".join(x["id"] for x in permissions) # Make this StageView work when injected, in which case `current_stage` is an instance # of the base class, and we don't save any consent, as it is assumed to be a one-time # prompt @@ -91,12 +118,16 @@ class ConsentStageView(ChallengeStageView): # Since we only get here when no consent exists, we can create it without update if current_stage.mode == ConsentMode.PERMANENT: UserConsent.objects.create( - user=self.request.user, application=application, expiring=False + user=self.request.user, + application=application, + expiring=False, + permissions=permissions_string, ) if current_stage.mode == ConsentMode.EXPIRING: UserConsent.objects.create( user=self.request.user, application=application, expires=now() + timedelta_from_string(current_stage.consent_expire_in), + permissions=permissions_string, ) return self.executor.stage_ok() diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py index e6fe2a127..37d72ed82 100644 --- a/authentik/stages/consent/tests.py +++ b/authentik/stages/consent/tests.py @@ -6,12 +6,15 @@ from django.urls import reverse from authentik.core.models import Application from authentik.core.tasks import clean_expired_models from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.flows.challenge import PermissionDict from authentik.flows.markers import StageMarker from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import SESSION_KEY_PLAN +from authentik.lib.generators import generate_id from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent +from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_PERMISSIONS class TestConsentStage(FlowTestCase): @@ -21,14 +24,14 @@ class TestConsentStage(FlowTestCase): super().setUp() self.user = create_test_admin_user() self.application = Application.objects.create( - name="test-application", - slug="test-application", + name=generate_id(), + slug=generate_id(), ) def test_always_required(self): """Test always required consent""" flow = create_test_flow(FlowDesignation.AUTHENTICATION) - stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.ALWAYS_REQUIRE) + stage = ConsentStage.objects.create(name=generate_id(), mode=ConsentMode.ALWAYS_REQUIRE) binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[binding], markers=[StageMarker()]) @@ -48,7 +51,7 @@ class TestConsentStage(FlowTestCase): """Test permanent consent from user""" self.client.force_login(self.user) flow = create_test_flow(FlowDesignation.AUTHENTICATION) - stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) + stage = ConsentStage.objects.create(name=generate_id(), mode=ConsentMode.PERMANENT) binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) plan = FlowPlan( @@ -75,7 +78,7 @@ class TestConsentStage(FlowTestCase): self.client.force_login(self.user) flow = create_test_flow(FlowDesignation.AUTHENTICATION) stage = ConsentStage.objects.create( - name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" + name=generate_id(), mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" ) binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) @@ -88,6 +91,18 @@ class TestConsentStage(FlowTestCase): session = self.client.session session[SESSION_KEY_PLAN] = plan session.save() + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageResponse( + response, + flow, + self.user, + permissions=[], + additional_permissions=[], + ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), {}, @@ -102,3 +117,95 @@ class TestConsentStage(FlowTestCase): self.assertFalse( UserConsent.objects.filter(user=self.user, application=self.application).exists() ) + + def test_permanent_more_perms(self): + """Test permanent consent from user""" + self.client.force_login(self.user) + flow = create_test_flow(FlowDesignation.AUTHENTICATION) + stage = ConsentStage.objects.create(name=generate_id(), mode=ConsentMode.PERMANENT) + binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan( + flow_pk=flow.pk.hex, + bindings=[binding], + markers=[StageMarker()], + context={ + PLAN_CONTEXT_APPLICATION: self.application, + PLAN_CONTEXT_CONSENT_PERMISSIONS: [PermissionDict(id="foo", name="foo-desc")], + }, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + # First, consent with a single permission + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageResponse( + response, + flow, + self.user, + permissions=[ + {"id": "foo", "name": "foo-desc"}, + ], + additional_permissions=[], + ) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application, permissions="foo" + ).exists() + ) + + # Request again with more perms + plan = FlowPlan( + flow_pk=flow.pk.hex, + bindings=[binding], + markers=[StageMarker()], + context={ + PLAN_CONTEXT_APPLICATION: self.application, + PLAN_CONTEXT_CONSENT_PERMISSIONS: [ + PermissionDict(id="foo", name="foo-desc"), + PermissionDict(id="bar", name="bar-desc"), + ], + }, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageResponse( + response, + flow, + self.user, + permissions=[ + {"id": "foo", "name": "foo-desc"}, + ], + additional_permissions=[ + {"id": "bar", "name": "bar-desc"}, + ], + ) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application, permissions="foo bar" + ).exists() + ) diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index c06025d00..b793aecee 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-01-03 12:29+0000\n" +"POT-Creation-Date: 2022-06-26 11:48+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: authentik/admin/api/tasks.py:95 +#: authentik/admin/api/tasks.py:99 #, python-format msgid "Successfully re-scheduled Task %(name)s!" msgstr "" @@ -39,113 +39,125 @@ msgstr "" msgid "Create a SAML Provider by importing its Metadata." msgstr "" -#: authentik/core/models.py:69 +#: authentik/core/api/users.py:90 +msgid "No leading or trailing slashes allowed." +msgstr "" + +#: authentik/core/api/users.py:93 +msgid "No empty segments in user path allowed." +msgstr "" + +#: authentik/core/models.py:76 msgid "name" msgstr "" -#: authentik/core/models.py:71 +#: authentik/core/models.py:78 msgid "Users added to this group will be superusers." msgstr "" -#: authentik/core/models.py:129 +#: authentik/core/models.py:146 msgid "User's display name." msgstr "" -#: authentik/core/models.py:212 authentik/providers/oauth2/models.py:299 +#: authentik/core/models.py:237 authentik/providers/oauth2/models.py:318 msgid "User" msgstr "" -#: authentik/core/models.py:213 +#: authentik/core/models.py:238 msgid "Users" msgstr "" -#: authentik/core/models.py:224 +#: authentik/core/models.py:249 msgid "Flow used when authorizing this provider." msgstr "" -#: authentik/core/models.py:257 +#: authentik/core/models.py:282 msgid "Application's display Name." msgstr "" -#: authentik/core/models.py:258 +#: authentik/core/models.py:283 msgid "Internal application name, used in URLs." msgstr "" -#: authentik/core/models.py:311 +#: authentik/core/models.py:295 +msgid "Open launch URL in a new browser tab or window." +msgstr "" + +#: authentik/core/models.py:354 msgid "Application" msgstr "" -#: authentik/core/models.py:312 +#: authentik/core/models.py:355 msgid "Applications" msgstr "" -#: authentik/core/models.py:318 +#: authentik/core/models.py:361 msgid "Use the source-specific identifier" msgstr "" -#: authentik/core/models.py:326 +#: authentik/core/models.py:369 msgid "" "Use the user's email address, but deny enrollment when the email address " "already exists." msgstr "" -#: authentik/core/models.py:335 +#: authentik/core/models.py:378 msgid "" "Use the user's username, but deny enrollment when the username already " "exists." msgstr "" -#: authentik/core/models.py:342 +#: authentik/core/models.py:385 msgid "Source's display Name." msgstr "" -#: authentik/core/models.py:343 +#: authentik/core/models.py:386 msgid "Internal source name, used in URLs." msgstr "" -#: authentik/core/models.py:354 +#: authentik/core/models.py:399 msgid "Flow to use when authenticating existing users." msgstr "" -#: authentik/core/models.py:363 +#: authentik/core/models.py:408 msgid "Flow to use when enrolling new users." msgstr "" -#: authentik/core/models.py:501 +#: authentik/core/models.py:557 msgid "Token" msgstr "" -#: authentik/core/models.py:502 +#: authentik/core/models.py:558 msgid "Tokens" msgstr "" -#: authentik/core/models.py:545 +#: authentik/core/models.py:601 msgid "Property Mapping" msgstr "" -#: authentik/core/models.py:546 +#: authentik/core/models.py:602 msgid "Property Mappings" msgstr "" -#: authentik/core/models.py:582 +#: authentik/core/models.py:638 msgid "Authenticated Session" msgstr "" -#: authentik/core/models.py:583 +#: authentik/core/models.py:639 msgid "Authenticated Sessions" msgstr "" -#: authentik/core/sources/flow_manager.py:166 +#: authentik/core/sources/flow_manager.py:177 msgid "source" msgstr "" -#: authentik/core/sources/flow_manager.py:220 -#: authentik/core/sources/flow_manager.py:258 +#: authentik/core/sources/flow_manager.py:245 +#: authentik/core/sources/flow_manager.py:283 #, python-format msgid "Successfully authenticated with %(source)s!" msgstr "" -#: authentik/core/sources/flow_manager.py:239 +#: authentik/core/sources/flow_manager.py:264 #, python-format msgid "Successfully linked %(source)s!" msgstr "" @@ -156,8 +168,8 @@ msgstr "" #: authentik/core/templates/if/admin.html:18 #: authentik/core/templates/if/admin.html:24 -#: authentik/core/templates/if/flow.html:28 -#: authentik/core/templates/if/flow.html:34 +#: authentik/core/templates/if/flow.html:35 +#: authentik/core/templates/if/flow.html:41 #: authentik/core/templates/if/user.html:18 #: authentik/core/templates/if/user.html:24 msgid "Loading..." @@ -200,11 +212,18 @@ msgid "" " " msgstr "" -#: authentik/core/templates/login/base_full.html:65 +#: authentik/core/templates/login/base_full.html:89 msgid "Powered by authentik" msgstr "" -#: authentik/crypto/api.py:132 +#: authentik/core/views/apps.py:48 +#: authentik/providers/oauth2/views/authorize.py:356 +#: authentik/providers/saml/views/sso.py:69 +#, python-format +msgid "You're about to sign into %(application)s." +msgstr "" + +#: authentik/crypto/api.py:143 msgid "Subject-alt name" msgstr "" @@ -231,85 +250,89 @@ msgstr "" msgid "Successfully imported %(count)d files." msgstr "" -#: authentik/events/models.py:285 +#: authentik/events/models.py:288 msgid "Event" msgstr "" -#: authentik/events/models.py:286 +#: authentik/events/models.py:289 msgid "Events" msgstr "" -#: authentik/events/models.py:292 +#: authentik/events/models.py:295 +msgid "authentik inbuilt notifications" +msgstr "" + +#: authentik/events/models.py:296 msgid "Generic Webhook" msgstr "" -#: authentik/events/models.py:293 +#: authentik/events/models.py:297 msgid "Slack Webhook (Slack/Discord)" msgstr "" -#: authentik/events/models.py:294 +#: authentik/events/models.py:298 msgid "Email" msgstr "" -#: authentik/events/models.py:312 +#: authentik/events/models.py:316 msgid "" "Only send notification once, for example when sending a webhook into a chat " "channel." msgstr "" -#: authentik/events/models.py:357 +#: authentik/events/models.py:374 msgid "Severity" msgstr "" -#: authentik/events/models.py:362 +#: authentik/events/models.py:379 msgid "Dispatched for user" msgstr "" -#: authentik/events/models.py:439 +#: authentik/events/models.py:456 msgid "Notification Transport" msgstr "" -#: authentik/events/models.py:440 +#: authentik/events/models.py:457 msgid "Notification Transports" msgstr "" -#: authentik/events/models.py:446 +#: authentik/events/models.py:463 msgid "Notice" msgstr "" -#: authentik/events/models.py:447 +#: authentik/events/models.py:464 msgid "Warning" msgstr "" -#: authentik/events/models.py:448 +#: authentik/events/models.py:465 msgid "Alert" msgstr "" -#: authentik/events/models.py:468 +#: authentik/events/models.py:485 msgid "Notification" msgstr "" -#: authentik/events/models.py:469 +#: authentik/events/models.py:486 msgid "Notifications" msgstr "" -#: authentik/events/models.py:488 +#: authentik/events/models.py:506 msgid "Controls which severity level the created notifications will have." msgstr "" -#: authentik/events/models.py:508 +#: authentik/events/models.py:526 msgid "Notification Rule" msgstr "" -#: authentik/events/models.py:509 +#: authentik/events/models.py:527 msgid "Notification Rules" msgstr "" -#: authentik/events/models.py:530 +#: authentik/events/models.py:548 msgid "Notification Webhook Mapping" msgstr "" -#: authentik/events/models.py:531 +#: authentik/events/models.py:549 msgid "Notification Webhook Mappings" msgstr "" @@ -317,42 +340,52 @@ msgstr "" msgid "Task has not been run yet." msgstr "" -#: authentik/flows/api/flows.py:350 +#: authentik/flows/api/flows.py:227 authentik/flows/api/flows.py:249 +#, python-format +msgid "Policy (%(type)s)" +msgstr "" + +#: authentik/flows/api/flows.py:258 +#, python-format +msgid "Stage (%(type)s)" +msgstr "" + +#: authentik/flows/api/flows.py:373 #, python-format msgid "Flow not applicable to current user/request: %(messages)s" msgstr "" -#: authentik/flows/models.py:107 +#: authentik/flows/models.py:108 msgid "Visible in the URL." msgstr "" -#: authentik/flows/models.py:109 +#: authentik/flows/models.py:110 msgid "Shown as the Title in Flow pages." msgstr "" -#: authentik/flows/models.py:126 +#: authentik/flows/models.py:128 msgid "Background shown during execution" msgstr "" -#: authentik/flows/models.py:133 +#: authentik/flows/models.py:135 msgid "" "Enable compatibility mode, increases compatibility with password managers on " "mobile devices." msgstr "" -#: authentik/flows/models.py:178 +#: authentik/flows/models.py:180 msgid "Flow" msgstr "" -#: authentik/flows/models.py:179 +#: authentik/flows/models.py:181 msgid "Flows" msgstr "" -#: authentik/flows/models.py:209 +#: authentik/flows/models.py:211 msgid "Evaluate policies when the Stage is present to the user." msgstr "" -#: authentik/flows/models.py:216 +#: authentik/flows/models.py:218 msgid "" "Configure how the flow executor should handle an invalid response to a " "challenge. RETRY returns the error message and a similar challenge to the " @@ -360,19 +393,19 @@ msgid "" "RESTART_WITH_CONTEXT restarts the flow while keeping the current context." msgstr "" -#: authentik/flows/models.py:240 +#: authentik/flows/models.py:242 msgid "Flow Stage Binding" msgstr "" -#: authentik/flows/models.py:241 +#: authentik/flows/models.py:243 msgid "Flow Stage Bindings" msgstr "" -#: authentik/flows/models.py:291 +#: authentik/flows/models.py:293 msgid "Flow Token" msgstr "" -#: authentik/flows/models.py:292 +#: authentik/flows/models.py:294 msgid "Flow Tokens" msgstr "" @@ -384,7 +417,7 @@ msgstr "" msgid "Something went wrong! Please try again later." msgstr "" -#: authentik/lib/utils/time.py:24 +#: authentik/lib/utils/time.py:27 #, python-format msgid "%(value)s is not in the correct format of 'hours=3;minutes=1'." msgstr "" @@ -393,46 +426,46 @@ msgstr "" msgid "Managed by authentik" msgstr "" -#: authentik/outposts/api/service_connections.py:131 +#: authentik/outposts/api/service_connections.py:132 msgid "" "You can only use an empty kubeconfig when connecting to a local cluster." msgstr "" -#: authentik/outposts/api/service_connections.py:139 +#: authentik/outposts/api/service_connections.py:140 msgid "Invalid kubeconfig" msgstr "" -#: authentik/outposts/models.py:151 +#: authentik/outposts/models.py:154 msgid "Outpost Service-Connection" msgstr "" -#: authentik/outposts/models.py:152 +#: authentik/outposts/models.py:155 msgid "Outpost Service-Connections" msgstr "" -#: authentik/outposts/models.py:188 +#: authentik/outposts/models.py:191 msgid "" "Certificate/Key used for authentication. Can be left empty for no " "authentication." msgstr "" -#: authentik/outposts/models.py:201 +#: authentik/outposts/models.py:204 msgid "Docker Service-Connection" msgstr "" -#: authentik/outposts/models.py:202 +#: authentik/outposts/models.py:205 msgid "Docker Service-Connections" msgstr "" -#: authentik/outposts/models.py:227 +#: authentik/outposts/models.py:230 msgid "Kubernetes Service-Connection" msgstr "" -#: authentik/outposts/models.py:228 +#: authentik/outposts/models.py:231 msgid "Kubernetes Service-Connections" msgstr "" -#: authentik/policies/denied.py:24 +#: authentik/policies/denied.py:25 msgid "Access denied" msgstr "" @@ -476,26 +509,26 @@ msgstr "" msgid "Expression Policies" msgstr "" -#: authentik/policies/hibp/models.py:22 +#: authentik/policies/hibp/models.py:23 #: authentik/policies/password/models.py:24 msgid "Field key to check, field keys defined in Prompt stages are available." msgstr "" -#: authentik/policies/hibp/models.py:47 +#: authentik/policies/hibp/models.py:51 #: authentik/policies/password/models.py:57 msgid "Password not set in context" msgstr "" -#: authentik/policies/hibp/models.py:60 +#: authentik/policies/hibp/models.py:64 #, python-format msgid "Password exists on %(count)d online lists." msgstr "" -#: authentik/policies/hibp/models.py:66 +#: authentik/policies/hibp/models.py:70 msgid "Have I Been Pwned Policy" msgstr "" -#: authentik/policies/hibp/models.py:67 +#: authentik/policies/hibp/models.py:71 msgid "Have I Been Pwned Policies" msgstr "" @@ -547,11 +580,11 @@ msgstr "" msgid "Password Policies" msgstr "" -#: authentik/policies/reputation/models.py:54 +#: authentik/policies/reputation/models.py:59 msgid "Reputation Policy" msgstr "" -#: authentik/policies/reputation/models.py:55 +#: authentik/policies/reputation/models.py:60 msgid "Reputation Policies" msgstr "" @@ -560,19 +593,27 @@ msgstr "" msgid "Permission denied" msgstr "" -#: authentik/policies/templates/policies/denied.html:20 +#: authentik/policies/templates/policies/denied.html:21 +msgid "User's avatar" +msgstr "" + +#: authentik/policies/templates/policies/denied.html:25 +msgid "Not you?" +msgstr "" + +#: authentik/policies/templates/policies/denied.html:33 msgid "Request has been denied." msgstr "" -#: authentik/policies/templates/policies/denied.html:31 +#: authentik/policies/templates/policies/denied.html:44 msgid "Messages:" msgstr "" -#: authentik/policies/templates/policies/denied.html:41 +#: authentik/policies/templates/policies/denied.html:54 msgid "Explanation:" msgstr "" -#: authentik/policies/templates/policies/denied.html:45 +#: authentik/policies/templates/policies/denied.html:58 #, python-format msgid "" "\n" @@ -609,188 +650,210 @@ msgid "" "primary groups gidNumber" msgstr "" -#: authentik/providers/ldap/models.py:97 +#: authentik/providers/ldap/models.py:98 msgid "LDAP Provider" msgstr "" -#: authentik/providers/ldap/models.py:98 +#: authentik/providers/ldap/models.py:99 msgid "LDAP Providers" msgstr "" -#: authentik/providers/oauth2/models.py:36 +#: authentik/providers/oauth2/models.py:37 msgid "Confidential" msgstr "" -#: authentik/providers/oauth2/models.py:37 +#: authentik/providers/oauth2/models.py:38 msgid "Public" msgstr "" -#: authentik/providers/oauth2/models.py:51 +#: authentik/providers/oauth2/models.py:60 msgid "Based on the Hashed User ID" msgstr "" -#: authentik/providers/oauth2/models.py:52 +#: authentik/providers/oauth2/models.py:61 msgid "Based on the username" msgstr "" -#: authentik/providers/oauth2/models.py:55 +#: authentik/providers/oauth2/models.py:64 msgid "Based on the User's Email. This is recommended over the UPN method." msgstr "" -#: authentik/providers/oauth2/models.py:71 +#: authentik/providers/oauth2/models.py:80 msgid "Same identifier is used for all providers" msgstr "" -#: authentik/providers/oauth2/models.py:73 +#: authentik/providers/oauth2/models.py:82 msgid "Each provider has a different issuer, based on the application slug." msgstr "" -#: authentik/providers/oauth2/models.py:80 +#: authentik/providers/oauth2/models.py:89 msgid "code (Authorization Code Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:81 +#: authentik/providers/oauth2/models.py:90 msgid "id_token (Implicit Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:82 +#: authentik/providers/oauth2/models.py:91 msgid "id_token token (Implicit Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:83 +#: authentik/providers/oauth2/models.py:92 msgid "code token (Hybrid Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:84 +#: authentik/providers/oauth2/models.py:93 msgid "code id_token (Hybrid Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:85 +#: authentik/providers/oauth2/models.py:94 msgid "code id_token token (Hybrid Flow)" msgstr "" -#: authentik/providers/oauth2/models.py:91 +#: authentik/providers/oauth2/models.py:100 msgid "HS256 (Symmetric Encryption)" msgstr "" -#: authentik/providers/oauth2/models.py:92 +#: authentik/providers/oauth2/models.py:101 msgid "RS256 (Asymmetric Encryption)" msgstr "" -#: authentik/providers/oauth2/models.py:93 +#: authentik/providers/oauth2/models.py:102 msgid "ES256 (Asymmetric Encryption)" msgstr "" -#: authentik/providers/oauth2/models.py:99 +#: authentik/providers/oauth2/models.py:108 msgid "Scope used by the client" msgstr "" -#: authentik/providers/oauth2/models.py:125 +#: authentik/providers/oauth2/models.py:134 msgid "Scope Mapping" msgstr "" -#: authentik/providers/oauth2/models.py:126 +#: authentik/providers/oauth2/models.py:135 msgid "Scope Mappings" msgstr "" -#: authentik/providers/oauth2/models.py:136 +#: authentik/providers/oauth2/models.py:145 msgid "Client Type" msgstr "" -#: authentik/providers/oauth2/models.py:142 +#: authentik/providers/oauth2/models.py:151 msgid "Client ID" msgstr "" -#: authentik/providers/oauth2/models.py:148 +#: authentik/providers/oauth2/models.py:157 msgid "Client Secret" msgstr "" -#: authentik/providers/oauth2/models.py:154 +#: authentik/providers/oauth2/models.py:163 msgid "Redirect URIs" msgstr "" -#: authentik/providers/oauth2/models.py:155 +#: authentik/providers/oauth2/models.py:164 msgid "Enter each URI on a new line." msgstr "" -#: authentik/providers/oauth2/models.py:160 +#: authentik/providers/oauth2/models.py:169 msgid "Include claims in id_token" msgstr "" -#: authentik/providers/oauth2/models.py:208 -msgid "RSA Key" +#: authentik/providers/oauth2/models.py:217 +msgid "Signing Key" msgstr "" -#: authentik/providers/oauth2/models.py:212 +#: authentik/providers/oauth2/models.py:221 msgid "" "Key used to sign the tokens. Only required when JWT Algorithm is set to " "RS256." msgstr "" -#: authentik/providers/oauth2/models.py:291 +#: authentik/providers/oauth2/models.py:228 +msgid "" +"Any JWT signed by the JWK of the selected source can be used to authenticate." +msgstr "" + +#: authentik/providers/oauth2/models.py:310 msgid "OAuth2/OpenID Provider" msgstr "" -#: authentik/providers/oauth2/models.py:292 +#: authentik/providers/oauth2/models.py:311 msgid "OAuth2/OpenID Providers" msgstr "" -#: authentik/providers/oauth2/models.py:300 +#: authentik/providers/oauth2/models.py:319 msgid "Scopes" msgstr "" -#: authentik/providers/oauth2/models.py:319 +#: authentik/providers/oauth2/models.py:338 msgid "Code" msgstr "" -#: authentik/providers/oauth2/models.py:320 +#: authentik/providers/oauth2/models.py:339 msgid "Nonce" msgstr "" -#: authentik/providers/oauth2/models.py:321 +#: authentik/providers/oauth2/models.py:340 msgid "Is Authentication?" msgstr "" -#: authentik/providers/oauth2/models.py:322 +#: authentik/providers/oauth2/models.py:341 msgid "Code Challenge" msgstr "" -#: authentik/providers/oauth2/models.py:324 +#: authentik/providers/oauth2/models.py:343 msgid "Code Challenge Method" msgstr "" -#: authentik/providers/oauth2/models.py:338 +#: authentik/providers/oauth2/models.py:357 msgid "Authorization Code" msgstr "" -#: authentik/providers/oauth2/models.py:339 +#: authentik/providers/oauth2/models.py:358 msgid "Authorization Codes" msgstr "" -#: authentik/providers/oauth2/models.py:382 +#: authentik/providers/oauth2/models.py:401 msgid "Access Token" msgstr "" -#: authentik/providers/oauth2/models.py:383 +#: authentik/providers/oauth2/models.py:402 msgid "Refresh Token" msgstr "" -#: authentik/providers/oauth2/models.py:384 +#: authentik/providers/oauth2/models.py:403 msgid "ID Token" msgstr "" -#: authentik/providers/oauth2/models.py:387 +#: authentik/providers/oauth2/models.py:406 msgid "OAuth2 Token" msgstr "" -#: authentik/providers/oauth2/models.py:388 +#: authentik/providers/oauth2/models.py:407 msgid "OAuth2 Tokens" msgstr "" -#: authentik/providers/oauth2/views/authorize.py:458 -#: authentik/providers/saml/views/sso.py:69 +#: authentik/providers/oauth2/views/authorize.py:410 +#: authentik/providers/saml/views/flows.py:86 #, python-format -msgid "You're about to sign into %(application)s." +msgid "Redirecting to %(app)s..." +msgstr "" + +#: authentik/providers/oauth2/views/userinfo.py:43 +#: authentik/providers/oauth2/views/userinfo.py:44 +msgid "GitHub Compatibility: Access your User Information" +msgstr "" + +#: authentik/providers/oauth2/views/userinfo.py:45 +msgid "GitHub Compatibility: Access you Email addresses" +msgstr "" + +#: authentik/providers/oauth2/views/userinfo.py:46 +msgid "GitHub Compatibility: Access your Groups" +msgstr "" + +#: authentik/providers/oauth2/views/userinfo.py:47 +msgid "authentik API Access on behalf of your user" msgstr "" #: authentik/providers/proxy/models.py:52 @@ -832,11 +895,11 @@ msgstr "" msgid "Proxy Providers" msgstr "" -#: authentik/providers/saml/api.py:176 +#: authentik/providers/saml/api.py:177 msgid "Invalid XML Syntax" msgstr "" -#: authentik/providers/saml/api.py:186 +#: authentik/providers/saml/api.py:187 #, python-format msgid "Failed to import Metadata: %(message)s" msgstr "" @@ -857,39 +920,39 @@ msgstr "" msgid "NameID Property Mapping" msgstr "" -#: authentik/providers/saml/models.py:109 authentik/sources/saml/models.py:128 +#: authentik/providers/saml/models.py:109 authentik/sources/saml/models.py:139 msgid "SHA1" msgstr "" -#: authentik/providers/saml/models.py:110 authentik/sources/saml/models.py:129 +#: authentik/providers/saml/models.py:110 authentik/sources/saml/models.py:140 msgid "SHA256" msgstr "" -#: authentik/providers/saml/models.py:111 authentik/sources/saml/models.py:130 +#: authentik/providers/saml/models.py:111 authentik/sources/saml/models.py:141 msgid "SHA384" msgstr "" -#: authentik/providers/saml/models.py:112 authentik/sources/saml/models.py:131 +#: authentik/providers/saml/models.py:112 authentik/sources/saml/models.py:142 msgid "SHA512" msgstr "" -#: authentik/providers/saml/models.py:119 authentik/sources/saml/models.py:138 +#: authentik/providers/saml/models.py:119 authentik/sources/saml/models.py:149 msgid "RSA-SHA1" msgstr "" -#: authentik/providers/saml/models.py:120 authentik/sources/saml/models.py:139 +#: authentik/providers/saml/models.py:120 authentik/sources/saml/models.py:150 msgid "RSA-SHA256" msgstr "" -#: authentik/providers/saml/models.py:121 authentik/sources/saml/models.py:140 +#: authentik/providers/saml/models.py:121 authentik/sources/saml/models.py:151 msgid "RSA-SHA384" msgstr "" -#: authentik/providers/saml/models.py:122 authentik/sources/saml/models.py:141 +#: authentik/providers/saml/models.py:122 authentik/sources/saml/models.py:152 msgid "RSA-SHA512" msgstr "" -#: authentik/providers/saml/models.py:123 authentik/sources/saml/models.py:142 +#: authentik/providers/saml/models.py:123 authentik/sources/saml/models.py:153 msgid "DSA-SHA1" msgstr "" @@ -901,7 +964,7 @@ msgstr "" msgid "Keypair used to sign outgoing Responses going to the Service Provider." msgstr "" -#: authentik/providers/saml/models.py:150 authentik/sources/saml/models.py:118 +#: authentik/providers/saml/models.py:150 authentik/sources/saml/models.py:129 msgid "Signing Keypair" msgstr "" @@ -1044,95 +1107,107 @@ msgstr "" msgid "URL used by authentik to get user information." msgstr "" -#: authentik/sources/oauth/models.py:97 +#: authentik/sources/oauth/models.py:48 +msgid "Additional Scopes" +msgstr "" + +#: authentik/sources/oauth/models.py:104 msgid "OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:98 +#: authentik/sources/oauth/models.py:105 msgid "OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:107 +#: authentik/sources/oauth/models.py:114 msgid "GitHub OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:108 +#: authentik/sources/oauth/models.py:115 msgid "GitHub OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:117 +#: authentik/sources/oauth/models.py:124 +msgid "Mailcow OAuth Source" +msgstr "" + +#: authentik/sources/oauth/models.py:125 +msgid "Mailcow OAuth Sources" +msgstr "" + +#: authentik/sources/oauth/models.py:134 msgid "Twitter OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:118 +#: authentik/sources/oauth/models.py:135 msgid "Twitter OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:127 +#: authentik/sources/oauth/models.py:144 msgid "Facebook OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:128 +#: authentik/sources/oauth/models.py:145 msgid "Facebook OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:137 +#: authentik/sources/oauth/models.py:154 msgid "Discord OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:138 +#: authentik/sources/oauth/models.py:155 msgid "Discord OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:147 +#: authentik/sources/oauth/models.py:164 msgid "Google OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:148 +#: authentik/sources/oauth/models.py:165 msgid "Google OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:157 +#: authentik/sources/oauth/models.py:174 msgid "Azure AD OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:158 +#: authentik/sources/oauth/models.py:175 msgid "Azure AD OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:167 +#: authentik/sources/oauth/models.py:184 msgid "OpenID OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:168 +#: authentik/sources/oauth/models.py:185 msgid "OpenID OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:177 +#: authentik/sources/oauth/models.py:194 msgid "Apple OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:178 +#: authentik/sources/oauth/models.py:195 msgid "Apple OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:187 +#: authentik/sources/oauth/models.py:204 msgid "Okta OAuth Source" msgstr "" -#: authentik/sources/oauth/models.py:188 +#: authentik/sources/oauth/models.py:205 msgid "Okta OAuth Sources" msgstr "" -#: authentik/sources/oauth/models.py:203 +#: authentik/sources/oauth/models.py:220 msgid "User OAuth Source Connection" msgstr "" -#: authentik/sources/oauth/models.py:204 +#: authentik/sources/oauth/models.py:221 msgid "User OAuth Source Connections" msgstr "" -#: authentik/sources/oauth/views/callback.py:98 +#: authentik/sources/oauth/views/callback.py:99 msgid "Authentication Failed." msgstr "" @@ -1164,72 +1239,72 @@ msgstr "" msgid "User Plex Source Connections" msgstr "" -#: authentik/sources/saml/models.py:36 +#: authentik/sources/saml/models.py:38 msgid "Redirect Binding" msgstr "" -#: authentik/sources/saml/models.py:37 +#: authentik/sources/saml/models.py:39 msgid "POST Binding" msgstr "" -#: authentik/sources/saml/models.py:38 +#: authentik/sources/saml/models.py:40 msgid "POST Binding with auto-confirmation" msgstr "" -#: authentik/sources/saml/models.py:57 +#: authentik/sources/saml/models.py:68 msgid "Flow used before authentication." msgstr "" -#: authentik/sources/saml/models.py:64 +#: authentik/sources/saml/models.py:75 msgid "Issuer" msgstr "" -#: authentik/sources/saml/models.py:65 +#: authentik/sources/saml/models.py:76 msgid "Also known as Entity ID. Defaults the Metadata URL." msgstr "" -#: authentik/sources/saml/models.py:69 +#: authentik/sources/saml/models.py:80 msgid "SSO URL" msgstr "" -#: authentik/sources/saml/models.py:70 +#: authentik/sources/saml/models.py:81 msgid "URL that the initial Login request is sent to." msgstr "" -#: authentik/sources/saml/models.py:76 +#: authentik/sources/saml/models.py:87 msgid "SLO URL" msgstr "" -#: authentik/sources/saml/models.py:77 +#: authentik/sources/saml/models.py:88 msgid "Optional URL if your IDP supports Single-Logout." msgstr "" -#: authentik/sources/saml/models.py:83 +#: authentik/sources/saml/models.py:94 msgid "" "Allows authentication flows initiated by the IdP. This can be a security " "risk, as no validation of the request ID is done." msgstr "" -#: authentik/sources/saml/models.py:91 +#: authentik/sources/saml/models.py:102 msgid "" "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent." msgstr "" -#: authentik/sources/saml/models.py:102 +#: authentik/sources/saml/models.py:113 msgid "Delete temporary users after" msgstr "" -#: authentik/sources/saml/models.py:120 +#: authentik/sources/saml/models.py:131 msgid "" "Keypair which is used to sign outgoing requests. Leave empty to disable " "signing." msgstr "" -#: authentik/sources/saml/models.py:188 +#: authentik/sources/saml/models.py:199 msgid "SAML Source" msgstr "" -#: authentik/sources/saml/models.py:189 +#: authentik/sources/saml/models.py:200 msgid "SAML Sources" msgstr "" @@ -1241,32 +1316,44 @@ msgstr "" msgid "Duo Authenticator Setup Stages" msgstr "" -#: authentik/stages/authenticator_duo/models.py:82 +#: authentik/stages/authenticator_duo/models.py:84 msgid "Duo Device" msgstr "" -#: authentik/stages/authenticator_duo/models.py:83 +#: authentik/stages/authenticator_duo/models.py:85 msgid "Duo Devices" msgstr "" -#: authentik/stages/authenticator_sms/models.py:157 +#: authentik/stages/authenticator_sms/models.py:53 +msgid "" +"When enabled, the Phone number is only used during enrollment to verify the " +"users authenticity. Only a hash of the phone number is saved to ensure it is " +"not re-used in the future." +msgstr "" + +#: authentik/stages/authenticator_sms/models.py:167 msgid "SMS Authenticator Setup Stage" msgstr "" -#: authentik/stages/authenticator_sms/models.py:158 +#: authentik/stages/authenticator_sms/models.py:168 msgid "SMS Authenticator Setup Stages" msgstr "" -#: authentik/stages/authenticator_sms/models.py:175 +#: authentik/stages/authenticator_sms/models.py:207 msgid "SMS Device" msgstr "" -#: authentik/stages/authenticator_sms/models.py:176 +#: authentik/stages/authenticator_sms/models.py:208 msgid "SMS Devices" msgstr "" -#: authentik/stages/authenticator_sms/stage.py:54 -#: authentik/stages/authenticator_totp/stage.py:45 +#: authentik/stages/authenticator_sms/stage.py:56 +msgid "Invalid phone number" +msgstr "" + +#: authentik/stages/authenticator_sms/stage.py:61 +#: authentik/stages/authenticator_totp/stage.py:39 +#: authentik/stages/authenticator_totp/stage.py:42 msgid "Code does not match" msgstr "" @@ -1294,51 +1381,55 @@ msgstr "" msgid "TOTP Authenticator Setup Stages" msgstr "" -#: authentik/stages/authenticator_validate/challenge.py:99 +#: authentik/stages/authenticator_validate/challenge.py:110 msgid "Invalid Token" msgstr "" #: authentik/stages/authenticator_validate/models.py:17 -msgid "TOTP" +msgid "Static" msgstr "" #: authentik/stages/authenticator_validate/models.py:18 -msgid "WebAuthn" +msgid "TOTP" msgstr "" #: authentik/stages/authenticator_validate/models.py:19 -msgid "Duo" +msgid "WebAuthn" msgstr "" #: authentik/stages/authenticator_validate/models.py:20 +msgid "Duo" +msgstr "" + +#: authentik/stages/authenticator_validate/models.py:21 msgid "SMS" msgstr "" -#: authentik/stages/authenticator_validate/models.py:58 +#: authentik/stages/authenticator_validate/models.py:57 msgid "Device classes which can be used to authenticate" msgstr "" -#: authentik/stages/authenticator_validate/models.py:80 +#: authentik/stages/authenticator_validate/models.py:90 msgid "Authenticator Validation Stage" msgstr "" -#: authentik/stages/authenticator_validate/models.py:81 +#: authentik/stages/authenticator_validate/models.py:91 msgid "Authenticator Validation Stages" msgstr "" -#: authentik/stages/authenticator_webauthn/models.py:71 +#: authentik/stages/authenticator_webauthn/models.py:112 msgid "WebAuthn Authenticator Setup Stage" msgstr "" -#: authentik/stages/authenticator_webauthn/models.py:72 +#: authentik/stages/authenticator_webauthn/models.py:113 msgid "WebAuthn Authenticator Setup Stages" msgstr "" -#: authentik/stages/authenticator_webauthn/models.py:105 +#: authentik/stages/authenticator_webauthn/models.py:146 msgid "WebAuthn Device" msgstr "" -#: authentik/stages/authenticator_webauthn/models.py:106 +#: authentik/stages/authenticator_webauthn/models.py:147 msgid "WebAuthn Devices" msgstr "" @@ -1360,19 +1451,19 @@ msgstr "" msgid "Captcha Stages" msgstr "" -#: authentik/stages/consent/models.py:52 +#: authentik/stages/consent/models.py:50 msgid "Consent Stage" msgstr "" -#: authentik/stages/consent/models.py:53 +#: authentik/stages/consent/models.py:51 msgid "Consent Stages" msgstr "" -#: authentik/stages/consent/models.py:68 +#: authentik/stages/consent/models.py:67 msgid "User Consent" msgstr "" -#: authentik/stages/consent/models.py:69 +#: authentik/stages/consent/models.py:68 msgid "User Consents" msgstr "" @@ -1416,15 +1507,15 @@ msgstr "" msgid "Email Stages" msgstr "" -#: authentik/stages/email/stage.py:106 +#: authentik/stages/email/stage.py:108 msgid "Successfully verified Email." msgstr "" -#: authentik/stages/email/stage.py:113 authentik/stages/email/stage.py:135 +#: authentik/stages/email/stage.py:115 authentik/stages/email/stage.py:137 msgid "No pending user." msgstr "" -#: authentik/stages/email/stage.py:125 +#: authentik/stages/email/stage.py:127 msgid "Email sent." msgstr "" @@ -1536,7 +1627,7 @@ msgstr "" msgid "Identification Stages" msgstr "" -#: authentik/stages/identification/stage.py:175 +#: authentik/stages/identification/stage.py:176 msgid "Log in" msgstr "" @@ -1548,19 +1639,19 @@ msgstr "" msgid "Invitation Stages" msgstr "" -#: authentik/stages/invitation/models.py:57 +#: authentik/stages/invitation/models.py:59 msgid "When enabled, the invitation will be deleted after usage." msgstr "" -#: authentik/stages/invitation/models.py:64 +#: authentik/stages/invitation/models.py:66 msgid "Optional fixed data to enforce on user enrollment." msgstr "" -#: authentik/stages/invitation/models.py:72 +#: authentik/stages/invitation/models.py:74 msgid "Invitation" msgstr "" -#: authentik/stages/invitation/models.py:73 +#: authentik/stages/invitation/models.py:75 msgid "Invitations" msgstr "" @@ -1588,55 +1679,59 @@ msgstr "" msgid "Password Stages" msgstr "" -#: authentik/stages/password/stage.py:152 +#: authentik/stages/password/stage.py:160 msgid "Invalid password" msgstr "" -#: authentik/stages/prompt/models.py:29 +#: authentik/stages/prompt/models.py:38 msgid "Text: Simple Text input" msgstr "" -#: authentik/stages/prompt/models.py:32 +#: authentik/stages/prompt/models.py:41 msgid "Text (read-only): Simple Text input, but cannot be edited." msgstr "" -#: authentik/stages/prompt/models.py:39 +#: authentik/stages/prompt/models.py:48 msgid "Email: Text field with Email type." msgstr "" -#: authentik/stages/prompt/models.py:55 +#: authentik/stages/prompt/models.py:64 msgid "Separator: Static Separator Line" msgstr "" -#: authentik/stages/prompt/models.py:56 +#: authentik/stages/prompt/models.py:65 msgid "Hidden: Hidden field, can be used to insert data into form." msgstr "" -#: authentik/stages/prompt/models.py:57 +#: authentik/stages/prompt/models.py:66 msgid "Static: Static value, displayed as-is." msgstr "" -#: authentik/stages/prompt/models.py:66 +#: authentik/stages/prompt/models.py:68 +msgid "authentik: Selection of locales authentik supports" +msgstr "" + +#: authentik/stages/prompt/models.py:77 msgid "Name of the form field, also used to store the value" msgstr "" -#: authentik/stages/prompt/models.py:131 +#: authentik/stages/prompt/models.py:170 msgid "Prompt" msgstr "" -#: authentik/stages/prompt/models.py:132 +#: authentik/stages/prompt/models.py:171 msgid "Prompts" msgstr "" -#: authentik/stages/prompt/models.py:160 +#: authentik/stages/prompt/models.py:199 msgid "Prompt Stage" msgstr "" -#: authentik/stages/prompt/models.py:161 +#: authentik/stages/prompt/models.py:200 msgid "Prompt Stages" msgstr "" -#: authentik/stages/prompt/stage.py:94 +#: authentik/stages/prompt/stage.py:95 msgid "Passwords don't match." msgstr "" @@ -1648,7 +1743,7 @@ msgstr "" msgid "User Delete Stages" msgstr "" -#: authentik/stages/user_delete/stage.py:24 +#: authentik/stages/user_delete/stage.py:22 msgid "No Pending User." msgstr "" @@ -1666,11 +1761,11 @@ msgstr "" msgid "User Login Stages" msgstr "" -#: authentik/stages/user_login/stage.py:29 +#: authentik/stages/user_login/stage.py:27 msgid "No Pending user to login." msgstr "" -#: authentik/stages/user_login/stage.py:57 +#: authentik/stages/user_login/stage.py:55 msgid "Successfully logged in!" msgstr "" @@ -1690,15 +1785,15 @@ msgstr "" msgid "Optionally add newly created users to this group." msgstr "" -#: authentik/stages/user_write/models.py:47 +#: authentik/stages/user_write/models.py:52 msgid "User Write Stage" msgstr "" -#: authentik/stages/user_write/models.py:48 +#: authentik/stages/user_write/models.py:53 msgid "User Write Stages" msgstr "" -#: authentik/stages/user_write/stage.py:53 +#: authentik/stages/user_write/stage.py:112 msgid "No Pending data." msgstr "" @@ -1708,10 +1803,10 @@ msgid "" "and `ba.b`" msgstr "" -#: authentik/tenants/models.py:70 +#: authentik/tenants/models.py:75 msgid "Tenant" msgstr "" -#: authentik/tenants/models.py:71 +#: authentik/tenants/models.py:76 msgid "Tenants" msgstr "" diff --git a/schema.yml b/schema.yml index 605369ced..a712e9531 100644 --- a/schema.yml +++ b/schema.yml @@ -20510,8 +20510,12 @@ components: type: array items: $ref: '#/components/schemas/Permission' + additional_permissions: + type: array + items: + $ref: '#/components/schemas/Permission' required: - - header_text + - additional_permissions - pending_user - pending_user_avatar - permissions @@ -31215,6 +31219,9 @@ components: $ref: '#/components/schemas/User' application: $ref: '#/components/schemas/Application' + permissions: + type: string + default: '' required: - application - pk diff --git a/web/src/flows/stages/consent/ConsentStage.ts b/web/src/flows/stages/consent/ConsentStage.ts index ea53dc901..af6bf991d 100644 --- a/web/src/flows/stages/consent/ConsentStage.ts +++ b/web/src/flows/stages/consent/ConsentStage.ts @@ -36,6 +36,66 @@ export class ConsentStage extends BaseStage +

${this.challenge.headerText}

+ ${this.challenge.permissions.length > 0 + ? html` +

+ ${t`Application requires following permissions:`} +

+
    + ${this.challenge.permissions.map((permission) => { + return html`
  • + ${permission.name} +
  • `; + })} +
+ ` + : html``} + + `; + } + + renderAdditional(): TemplateResult { + return html` +
+

${this.challenge.headerText}

+ ${this.challenge.permissions.length > 0 + ? html` +

+ ${t`Application already has access to the following permissions:`} +

+
    + ${this.challenge.permissions.map((permission) => { + return html`
  • + ${permission.name} +
  • `; + })} +
+ ` + : html``} +
+
+ ${this.challenge.additionalPermissions.length > 0 + ? html` + + ${t`Application requires following new permissions:`} + +
    + ${this.challenge.additionalPermissions.map((permission) => { + return html`
  • + ${permission.name} +
  • `; + })} +
+ ` + : html``} +
+ `; + } + render(): TemplateResult { if (!this.challenge) { return html` `; @@ -61,23 +121,9 @@ export class ConsentStage extends BaseStage -
-

${this.challenge.headerText}

- ${this.challenge.permissions.length > 0 - ? html` -

- ${t`Application requires following permissions:`} -

-
    - ${this.challenge.permissions.map((permission) => { - return html`
  • - ${permission.name} -
  • `; - })} -
- ` - : html``} -
+ ${this.challenge.additionalPermissions.length > 0 + ? this.renderAdditional() + : this.renderNoPrevious()}