Compare commits

..

2 Commits

Author SHA1 Message Date
e3f2ed0436 web: Prep for hash-less routing. 2025-04-03 15:19:19 +02:00
a5bb22a66a web: Move build observer. Prep. 2025-04-03 06:12:51 +02:00
173 changed files with 4692 additions and 4382 deletions

View File

@ -36,6 +36,11 @@ jobs:
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@v7
id: cpr
with:

View File

@ -30,6 +30,7 @@ WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev
@ -93,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.12 AS uv
FROM ghcr.io/astral-sh/uv:0.6.11 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base

View File

@ -179,13 +179,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
"user",
"source",
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -202,7 +199,7 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer
filterset_fields = ["user", "source__slug"]
search_fields = ["user__username", "source__slug", "identifier"]
search_fields = ["source__slug"]
ordering = ["source__slug", "pk"]
owner_field = "user"
@ -221,11 +218,9 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -242,5 +237,6 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer
filterset_fields = ["group", "source__slug"]
search_fields = ["group__name", "source__slug", "identifier"]
search_fields = ["source__slug"]
ordering = ["source__slug", "pk"]
owner_field = "user"

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.13 on 2025-04-07 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0043_alter_group_options"),
]
operations = [
migrations.AddField(
model_name="usersourceconnection",
name="new_identifier",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -1,30 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0044_usersourceconnection_new_identifier"),
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"),
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
]
operations = [
migrations.RenameField(
model_name="usersourceconnection",
old_name="new_identifier",
new_name="identifier",
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"),
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(
fields=["source", "identifier"], name="authentik_c_source__649e04_idx"
),
),
]

View File

@ -824,7 +824,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager()
@ -838,10 +837,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class Meta:
unique_together = (("user", "source"),)
indexes = (
models.Index(fields=("identifier",)),
models.Index(fields=("source", "identifier")),
)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):

View File

@ -13,11 +13,7 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.property_mappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import (
GroupSourceConnectionViewSet,
SourceViewSet,
UserSourceConnectionViewSet,
)
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet
@ -85,7 +81,6 @@ api_urlpatterns = [
("core/tokens", TokenViewSet),
("sources/all", SourceViewSet),
("sources/user_connections/all", UserSourceConnectionViewSet),
("sources/group_connections/all", GroupSourceConnectionViewSet),
("providers/all", ProviderViewSet),
("propertymappings/all", PropertyMappingViewSet),
("authenticators/all", DeviceViewSet, "device"),

View File

@ -49,6 +49,6 @@
</main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div>
<script src="{% static 'dist/sfe/main.js' %}"></script>
<script src="{% static 'dist/sfe/index.js' %}"></script>
</body>
</html>

View File

@ -1,11 +1,13 @@
"""Kerberos Source Serializer"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import (
GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet,
UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
)
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.kerberos.models import (
GroupKerberosSourceConnection,
UserKerberosSourceConnection,
@ -13,20 +15,33 @@ from authentik.sources.kerberos.models import (
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta):
"""Kerberos Source Serializer"""
class Meta:
model = UserKerberosSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
"""Source Viewset"""
queryset = UserKerberosSourceConnection.objects.all()
serializer_class = UserKerberosSourceConnectionSerializer
filterset_fields = ["source__slug"]
search_fields = ["source__slug"]
ordering = ["source__slug"]
owner_field = "user"
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupKerberosSourceConnection
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupKerberosSourceConnection.objects.all()
serializer_class = GroupKerberosSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserKerberosSourceConnection = apps.get_model(
"authentik_sources_kerberos", "UserKerberosSourceConnection"
)
for connection in UserKerberosSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_kerberos", "0002_kerberossource_kadmin_type"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userkerberossourceconnection",
name="identifier",
),
]

View File

@ -372,6 +372,8 @@ class KerberosSourcePropertyMapping(PropertyMapping):
class UserKerberosSourceConnection(UserSourceConnection):
"""Connection to configured Kerberos Sources."""
identifier = models.TextField()
@property
def serializer(self) -> type[Serializer]:
from authentik.sources.kerberos.api.source_connection import (

View File

@ -1,3 +1,5 @@
"""OAuth Source Serializer"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import (
@ -10,9 +12,11 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
"""OAuth Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta):
model = UserOAuthSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"]
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs,
"access_token": {"write_only": True},
@ -20,15 +24,21 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserOAuthSourceConnection.objects.all()
serializer_class = UserOAuthSourceConnectionSerializer
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupOAuthSourceConnection
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupOAuthSourceConnection.objects.all()
serializer_class = GroupOAuthSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserOAuthSourceConnection = apps.get_model(
"authentik_sources_oauth", "UserOAuthSourceConnection"
)
for connection in UserOAuthSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_oauth", "0008_groupoauthsourceconnection_and_more"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="useroauthsourceconnection",
name="identifier",
),
]

View File

@ -286,6 +286,7 @@ class OAuthSourcePropertyMapping(PropertyMapping):
class UserOAuthSourceConnection(UserSourceConnection):
"""Authorized remote OAuth provider."""
identifier = models.CharField(max_length=255)
access_token = models.TextField(blank=True, null=True, default=None)
@property

View File

@ -1,3 +1,5 @@
"""Plex Source connection Serializer"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import (
@ -10,9 +12,14 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
"""Plex Source connection Serializer"""
class Meta(UserSourceConnectionSerializer.Meta):
model = UserPlexSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"]
fields = UserSourceConnectionSerializer.Meta.fields + [
"identifier",
"plex_token",
]
extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs,
"plex_token": {"write_only": True},
@ -20,15 +27,21 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Plex Source connection Serializer"""
queryset = UserPlexSourceConnection.objects.all()
serializer_class = UserPlexSourceConnectionSerializer
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""Plex Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupPlexSourceConnection
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupPlexSourceConnection.objects.all()
serializer_class = GroupPlexSourceConnectionSerializer

View File

@ -1,29 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserPlexSourceConnection = apps.get_model("authentik_sources_plex", "UserPlexSourceConnection")
for connection in UserPlexSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
(
"authentik_sources_plex",
"0004_groupplexsourceconnection_plexsourcepropertymapping_and_more",
),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userplexsourceconnection",
name="identifier",
),
]

View File

@ -141,6 +141,7 @@ class UserPlexSourceConnection(UserSourceConnection):
"""Connect user and plex source"""
plex_token = models.TextField()
identifier = models.TextField()
@property
def serializer(self) -> type[Serializer]:

View File

@ -1,3 +1,5 @@
"""SAML Source Serializer"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import (
@ -10,20 +12,29 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
"""SAML Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta):
model = UserSAMLSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserSAMLSourceConnection.objects.all()
serializer_class = UserSAMLSourceConnectionSerializer
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupSAMLSourceConnection
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupSAMLSourceConnection.objects.all()
serializer_class = GroupSAMLSourceConnectionSerializer

View File

@ -1,26 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserSAMLSourceConnection = apps.get_model("authentik_sources_saml", "UserSAMLSourceConnection")
for connection in UserSAMLSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0018_alter_samlsource_slo_url_alter_samlsource_sso_url"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="usersamlsourceconnection",
name="identifier",
),
]

View File

@ -318,6 +318,8 @@ class SAMLSourcePropertyMapping(PropertyMapping):
class UserSAMLSourceConnection(UserSourceConnection):
"""Connection to configured SAML Sources."""
identifier = models.TextField()
@property
def serializer(self) -> Serializer:
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer

View File

@ -104,13 +104,6 @@ def send_mail(
# can't be converted to json)
message_object.attach(logo_data())
if (
message_object.to
and isinstance(message_object.to[0], str)
and "=?utf-8?" in message_object.to[0]
):
message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")]
LOGGER.debug("Sending mail", to=message_object.to)
backend.send_messages([message_object])
Event.new(

View File

@ -97,37 +97,6 @@ class TestEmailStageSending(FlowTestCase):
self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
def test_utf8_name(self):
"""Test with pending user"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
utf8_user = create_test_user()
utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ "
utf8_user.email = "cyrillic@authentik.local"
utf8_user.save()
plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"])
def test_pending_fake_user(self):
"""Test with pending (fake) user"""
self.flow.designation = FlowDesignation.RECOVERY

View File

@ -8231,6 +8231,7 @@
},
"identifier": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Identifier"
},

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1007.0",
"aws-cdk": "^2.1006.0",
"cross-env": "^7.0.3"
},
"engines": {
@ -17,9 +17,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1007.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz",
"integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==",
"version": "2.1006.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1006.0.tgz",
"integrity": "sha512-6qYnCt4mBN+3i/5F+FC2yMETkDHY/IL7gt3EuqKVPcaAO4jU7oXfVSlR60CYRkZWL4fnAurUV14RkJuJyVG/IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@ -10,7 +10,7 @@
"node": ">=20"
},
"devDependencies": {
"aws-cdk": "^2.1007.0",
"aws-cdk": "^2.1006.0",
"cross-env": "^7.0.3"
}
}

Binary file not shown.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2025.2.3",
"version": "2025.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2025.2.3"
"version": "2025.2.1"
}
}
}

View File

@ -52,7 +52,7 @@ dependencies = [
"pydantic-scim",
"pyjwt",
"pyrad",
"python-kadmin-rs ==0.6.0",
"python-kadmin-rs ==0.5.3",
"pyyaml",
"requests-oauthlib",
"scim2-filter-parser",

View File

@ -25938,243 +25938,6 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/all/:
get:
operationId: sources_group_connections_all_list
description: Group-source connection Viewset
parameters:
- in: query
name: group
schema:
type: string
format: uuid
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: source__slug
schema:
type: string
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedGroupSourceConnectionList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/all/{id}/:
get:
operationId: sources_group_connections_all_retrieve
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: sources_group_connections_all_update
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: sources_group_connections_all_partial_update
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedGroupSourceConnectionRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: sources_group_connections_all_destroy
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/all/{id}/used_by/:
get:
operationId: sources_group_connections_all_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/kerberos/:
get:
operationId: sources_group_connections_kerberos_list
@ -26236,38 +25999,6 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: sources_group_connections_kerberos_create
description: Group-source connection Viewset
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupKerberosSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupKerberosSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/kerberos/{id}/:
get:
operationId: sources_group_connections_kerberos_retrieve
@ -27048,38 +26779,6 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: sources_group_connections_saml_create
description: Group-source connection Viewset
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSAMLSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/saml/{id}/:
get:
operationId: sources_group_connections_saml_retrieve
@ -30293,7 +29992,7 @@ paths:
/sources/user_connections/kerberos/:
get:
operationId: sources_user_connections_kerberos_list
description: User-source connection Viewset
description: Source Viewset
parameters:
- name: ordering
required: false
@ -30323,10 +30022,6 @@ paths:
name: source__slug
schema:
type: string
- in: query
name: user
schema:
type: integer
tags:
- sources
security:
@ -30352,7 +30047,7 @@ paths:
description: ''
post:
operationId: sources_user_connections_kerberos_create
description: User-source connection Viewset
description: Source Viewset
tags:
- sources
requestBody:
@ -30385,7 +30080,7 @@ paths:
/sources/user_connections/kerberos/{id}/:
get:
operationId: sources_user_connections_kerberos_retrieve
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30419,7 +30114,7 @@ paths:
description: ''
put:
operationId: sources_user_connections_kerberos_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30459,7 +30154,7 @@ paths:
description: ''
patch:
operationId: sources_user_connections_kerberos_partial_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30498,7 +30193,7 @@ paths:
description: ''
delete:
operationId: sources_user_connections_kerberos_destroy
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30566,7 +30261,7 @@ paths:
/sources/user_connections/oauth/:
get:
operationId: sources_user_connections_oauth_list
description: User-source connection Viewset
description: Source Viewset
parameters:
- name: ordering
required: false
@ -30625,7 +30320,7 @@ paths:
description: ''
post:
operationId: sources_user_connections_oauth_create
description: User-source connection Viewset
description: Source Viewset
tags:
- sources
requestBody:
@ -30658,7 +30353,7 @@ paths:
/sources/user_connections/oauth/{id}/:
get:
operationId: sources_user_connections_oauth_retrieve
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30691,7 +30386,7 @@ paths:
description: ''
put:
operationId: sources_user_connections_oauth_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30730,7 +30425,7 @@ paths:
description: ''
patch:
operationId: sources_user_connections_oauth_partial_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30768,7 +30463,7 @@ paths:
description: ''
delete:
operationId: sources_user_connections_oauth_destroy
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -30834,7 +30529,7 @@ paths:
/sources/user_connections/plex/:
get:
operationId: sources_user_connections_plex_list
description: User-source connection Viewset
description: Plex Source connection Serializer
parameters:
- name: ordering
required: false
@ -30893,7 +30588,7 @@ paths:
description: ''
post:
operationId: sources_user_connections_plex_create
description: User-source connection Viewset
description: Plex Source connection Serializer
tags:
- sources
requestBody:
@ -30926,7 +30621,7 @@ paths:
/sources/user_connections/plex/{id}/:
get:
operationId: sources_user_connections_plex_retrieve
description: User-source connection Viewset
description: Plex Source connection Serializer
parameters:
- in: path
name: id
@ -30959,7 +30654,7 @@ paths:
description: ''
put:
operationId: sources_user_connections_plex_update
description: User-source connection Viewset
description: Plex Source connection Serializer
parameters:
- in: path
name: id
@ -30998,7 +30693,7 @@ paths:
description: ''
patch:
operationId: sources_user_connections_plex_partial_update
description: User-source connection Viewset
description: Plex Source connection Serializer
parameters:
- in: path
name: id
@ -31036,7 +30731,7 @@ paths:
description: ''
delete:
operationId: sources_user_connections_plex_destroy
description: User-source connection Viewset
description: Plex Source connection Serializer
parameters:
- in: path
name: id
@ -31102,7 +30797,7 @@ paths:
/sources/user_connections/saml/:
get:
operationId: sources_user_connections_saml_list
description: User-source connection Viewset
description: Source Viewset
parameters:
- name: ordering
required: false
@ -31161,7 +30856,7 @@ paths:
description: ''
post:
operationId: sources_user_connections_saml_create
description: User-source connection Viewset
description: Source Viewset
tags:
- sources
requestBody:
@ -31194,7 +30889,7 @@ paths:
/sources/user_connections/saml/{id}/:
get:
operationId: sources_user_connections_saml_retrieve
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -31227,7 +30922,7 @@ paths:
description: ''
put:
operationId: sources_user_connections_saml_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -31266,7 +30961,7 @@ paths:
description: ''
patch:
operationId: sources_user_connections_saml_partial_update
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -31304,7 +30999,7 @@ paths:
description: ''
delete:
operationId: sources_user_connections_saml_destroy
description: User-source connection Viewset
description: Source Viewset
parameters:
- in: path
name: id
@ -44758,7 +44453,7 @@ components:
- users_obj
GroupKerberosSourceConnection:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
pk:
type: integer
@ -44780,21 +44475,16 @@ components:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupKerberosSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -44894,7 +44584,7 @@ components:
- username
GroupOAuthSourceConnection:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
pk:
type: integer
@ -44916,21 +44606,16 @@ components:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupOAuthSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -44947,7 +44632,7 @@ components:
- source
GroupPlexSourceConnection:
type: object
description: Group Source Connection
description: Plex Group-Source connection Serializer
properties:
pk:
type: integer
@ -44969,21 +44654,16 @@ components:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupPlexSourceConnectionRequest:
type: object
description: Group Source Connection
description: Plex Group-Source connection Serializer
properties:
group:
type: string
@ -45028,7 +44708,7 @@ components:
- name
GroupSAMLSourceConnection:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
pk:
type: integer
@ -45050,74 +44730,16 @@ components:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupSAMLSourceConnectionRequest:
type: object
description: Group Source Connection
properties:
group:
type: string
format: uuid
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
required:
- group
- identifier
- source
GroupSourceConnection:
type: object
description: Group Source Connection
properties:
pk:
type: integer
readOnly: true
title: ID
group:
type: string
format: uuid
source:
type: string
format: uuid
source_obj:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -48743,18 +48365,6 @@ components:
required:
- pagination
- results
PaginatedGroupSourceConnectionList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/GroupSourceConnection'
required:
- pagination
- results
PaginatedIdentificationStageList:
type: object
properties:
@ -51135,7 +50745,7 @@ components:
the remote system.
PatchedGroupKerberosSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -51148,7 +50758,7 @@ components:
minLength: 1
PatchedGroupOAuthSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -51161,7 +50771,7 @@ components:
minLength: 1
PatchedGroupPlexSourceConnectionRequest:
type: object
description: Group Source Connection
description: Plex Group-Source connection Serializer
properties:
group:
type: string
@ -51200,20 +50810,7 @@ components:
format: uuid
PatchedGroupSAMLSourceConnectionRequest:
type: object
description: Group Source Connection
properties:
group:
type: string
format: uuid
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
PatchedGroupSourceConnectionRequest:
type: object
description: Group Source Connection
description: OAuth Group-Source connection Serializer
properties:
group:
type: string
@ -53186,7 +52783,7 @@ components:
$ref: '#/components/schemas/FlowSetRequest'
PatchedUserKerberosSourceConnectionRequest:
type: object
description: User source connection
description: Kerberos Source Serializer
properties:
user:
type: integer
@ -53243,7 +52840,7 @@ components:
$ref: '#/components/schemas/FlowSetRequest'
PatchedUserOAuthSourceConnectionRequest:
type: object
description: User source connection
description: OAuth Source Serializer
properties:
user:
type: integer
@ -53253,13 +52850,14 @@ components:
identifier:
type: string
minLength: 1
maxLength: 255
access_token:
type: string
writeOnly: true
nullable: true
PatchedUserPlexSourceConnectionRequest:
type: object
description: User source connection
description: Plex Source connection Serializer
properties:
user:
type: integer
@ -53313,7 +52911,7 @@ components:
$ref: '#/components/schemas/UserTypeEnum'
PatchedUserSAMLSourceConnectionRequest:
type: object
description: User source connection
description: SAML Source Serializer
properties:
user:
type: integer
@ -53332,9 +52930,6 @@ components:
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
PatchedUserWriteStageRequest:
type: object
description: UserWriteStage Serializer
@ -58422,7 +58017,7 @@ components:
- name
UserKerberosSourceConnection:
type: object
description: User source connection
description: Kerberos Source Serializer
properties:
pk:
type: integer
@ -58437,27 +58032,22 @@ components:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
identifier:
type: string
format: date-time
readOnly: true
required:
- created
- identifier
- last_updated
- pk
- source
- source_obj
- user
UserKerberosSourceConnectionRequest:
type: object
description: User source connection
description: Kerberos Source Serializer
properties:
user:
type: integer
@ -58684,7 +58274,7 @@ components:
- logins_failed
UserOAuthSourceConnection:
type: object
description: User source connection
description: OAuth Source Serializer
properties:
pk:
type: integer
@ -58699,27 +58289,23 @@ components:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
identifier:
type: string
format: date-time
readOnly: true
maxLength: 255
required:
- created
- identifier
- last_updated
- pk
- source
- source_obj
- user
UserOAuthSourceConnectionRequest:
type: object
description: User source connection
description: OAuth Source Serializer
properties:
user:
type: integer
@ -58729,6 +58315,7 @@ components:
identifier:
type: string
minLength: 1
maxLength: 255
access_token:
type: string
writeOnly: true
@ -58786,7 +58373,7 @@ components:
- paths
UserPlexSourceConnection:
type: object
description: User source connection
description: Plex Source connection Serializer
properties:
pk:
type: integer
@ -58801,27 +58388,22 @@ components:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
identifier:
type: string
format: date-time
readOnly: true
required:
- created
- identifier
- last_updated
- pk
- source
- source_obj
- user
UserPlexSourceConnectionRequest:
type: object
description: User source connection
description: Plex Source connection Serializer
properties:
user:
type: integer
@ -58883,7 +58465,7 @@ components:
- username
UserSAMLSourceConnection:
type: object
description: User source connection
description: SAML Source Serializer
properties:
pk:
type: integer
@ -58898,27 +58480,22 @@ components:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
identifier:
type: string
format: date-time
readOnly: true
required:
- created
- identifier
- last_updated
- pk
- source
- source_obj
- user
UserSAMLSourceConnectionRequest:
type: object
description: User source connection
description: SAML Source Serializer
properties:
user:
type: integer
@ -59082,20 +58659,12 @@ components:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- identifier
- last_updated
- pk
- source
- source_obj
@ -59109,11 +58678,7 @@ components:
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
required:
- identifier
- source
- user
UserTypeEnum:

18
uv.lock generated
View File

@ -310,7 +310,7 @@ requires-dist = [
{ name = "pydantic-scim" },
{ name = "pyjwt" },
{ name = "pyrad" },
{ name = "python-kadmin-rs", specifier = "==0.6.0" },
{ name = "python-kadmin-rs", specifier = "==0.5.3" },
{ name = "pyyaml" },
{ name = "requests-oauthlib" },
{ name = "scim2-filter-parser" },
@ -2599,16 +2599,16 @@ wheels = [
[[package]]
name = "python-kadmin-rs"
version = "0.6.0"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/ac/df3a093b1e186cd68a6f38778fac025450e5c5e9859c4790e00c2ed0ff62/python_kadmin_rs-0.6.0.tar.gz", hash = "sha256:dadd3d4ef542b829c1dcde97360a6b6a10700a4b5686f12f24b10f6cf5ca6e6c", size = 89318 }
sdist = { url = "https://files.pythonhosted.org/packages/e7/95/07b708623f13874ad86dc603f2fe36e980a5f5890edea87286d13f2b0b81/python_kadmin_rs-0.5.3.tar.gz", hash = "sha256:4f46fd854af622896136c3ac4fc5e6a37d37bfffb5b2023e438001ffa62ab7e3", size = 89865 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/6d/59fefe1c4c11177c4feb8ad65dd6a265e9cc5fc83682a928acdccb170000/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0069fbd656096b98853f8cdc6d5e24f754829fa9cb4a716dac33777f0305d37a", size = 1418187 },
{ url = "https://files.pythonhosted.org/packages/a6/12/c00a71c0fc17f5d208b4bb5e570002d74f0bc414e35194537d46ea32080f/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:cfcfe9982e969705dee62f2b97c8d7c249b55b2a97e2bc981408061ea7182b96", size = 1501759 },
{ url = "https://files.pythonhosted.org/packages/a0/b5/06cf809cfaaeded84e6634bf07116264ab4f8fd5eccca7523114e197f424/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:920df382e7a554d2f6fd160436a64adf1251f3262ec16bccd6d3b9f7e039d5fa", size = 3262691 },
{ url = "https://files.pythonhosted.org/packages/e6/72/99884dbc1856440a548ea8bf2ff1232c7f2823b6cb1a62bbb4d902a34609/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:94509b7470b18105c27fcaf5e6af894644614a687af74a43499735c405217e01", size = 3382996 },
{ url = "https://files.pythonhosted.org/packages/bd/4f/5d7e5be27cd466affc00fcab71fb94ea0420aee95306188988faf270b129/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f89e7fbcb7220a42c143a1b008685f98ca0a72ecc55c30f85b72c9d1ba9c3b9", size = 1572007 },
{ url = "https://files.pythonhosted.org/packages/a6/1e/fdd7d6cd2ebc4cc654112329311380d1c03c681511973e32ae6ab90f261c/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:775ce07ffd47a50ba27c8d74c20baacb56acfc7a8c56a8b02f2207ed9829156e", size = 1618897 },
{ url = "https://files.pythonhosted.org/packages/96/46/1bbfd7d6819851c300b991d7340452fba8edc3d2fe68b33271279eb74887/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:54b5e1c2e22da0d16c1418eb2b46da8baa11699a5db8db2afc52dbfd02d14958", size = 1416637 },
{ url = "https://files.pythonhosted.org/packages/be/34/fd7f5c324aaf1b9ad3dd5050ac2059230618c29adc452d676d2af4d5ae79/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d1dc7ad1f07bbfd09baeb1fb0dfc45c87776ed717052081e63d3bdba340a250e", size = 1503018 },
{ url = "https://files.pythonhosted.org/packages/e5/29/3931502534e07806cf7c70631374452cfcbafa44e75c5403416372b701c7/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86404a1060ece916088ae4a0d188e9309fd46e0b3003779ee7a8dc7493176779", size = 3268475 },
{ url = "https://files.pythonhosted.org/packages/ba/5d/f18ca5df97a4241711555987eb308c6e6c5505883514ac7f18d7aebd52f2/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7aa62a618af2b2112f708fd44f9cc3cf25e28f1562ea66a2036fb3cd1a47e649", size = 3371699 },
{ url = "https://files.pythonhosted.org/packages/91/d3/42c4d57414cfdf4e4ff528dd8e72428908ee67aeeae6a63fe2f5dbcd04bc/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80813af82dfbcc6a90505183c822eab11de77b6703e5691e37ed77d292224dd9", size = 1584049 },
{ url = "https://files.pythonhosted.org/packages/9a/65/705f179cf4bf4d16fc1daeac0810def57da2f4514a5b79ca60f24d7efb90/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6799a0faddb4ccf200acfa87da38e5fa2af54970d066b2c876e752bbf794b204", size = 1590360 },
]
[[package]]

View File

@ -1,206 +0,0 @@
/**
* @file WebAuthn utilities.
*/
import { fromByteArray } from "base64-js";
//@ts-check
//#region Type Definitions
/**
* @typedef {object} Assertion
* @property {string} id
* @property {string} rawId
* @property {string} type
* @property {string} registrationClientExtensions
* @property {object} response
* @property {string} response.clientDataJSON
* @property {string} response.attestationObject
*/
/**
* @typedef {object} AuthAssertion
* @property {string} id
* @property {string} rawId
* @property {string} type
* @property {string} assertionClientExtensions
* @property {object} response
* @property {string} response.clientDataJSON
* @property {string} response.authenticatorData
* @property {string} response.signature
* @property {string | null} response.userHandle
*/
//#endregion
//#region Encoding/Decoding
/**
* Encodes a byte array into a URL-safe base64 string.
*
* @param {Uint8Array} buffer
* @returns {string}
*/
export function encodeBase64(buffer) {
return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, "");
}
/**
* Encodes a byte array into a base64 string without URL-safe encoding, i.e., with padding.
* @param {Uint8Array} buffer
* @returns {string}
*/
export function encodeBase64Raw(buffer) {
return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_");
}
/**
* Decodes a base64 string into a byte array.
*
* @param {string} input
* @returns {Uint8Array}
*/
export function decodeBase64(input) {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
//#endregion
//#region Utility Functions
/**
* Checks if the browser supports WebAuthn.
*
* @returns {boolean}
*/
export function isWebAuthnSupported() {
if ("credentials" in navigator) return true;
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
return false;
}
console.warn("WebAuthn not supported by browser.");
return false;
}
/**
* Asserts that the browser supports WebAuthn and that we're in a secure context.
*
* @throws {Error} If WebAuthn is not supported.
*/
export function assertWebAuthnSupport() {
// Is the navigator exposing the credentials API?
if ("credentials" in navigator) return;
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
throw new Error("WebAuthn requires this page to be accessed via HTTPS.");
}
throw new Error("WebAuthn not supported by browser.");
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
* @param {PublicKeyCredentialCreationOptions} credentialCreateOptions
* @param {string} userID
* @returns {PublicKeyCredentialCreationOptions}
*/
export function transformCredentialCreateOptions(credentialCreateOptions, userID) {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userID));
user.id = decodeBase64(encodeBase64(decodeBase64(stringId)));
const challenge = decodeBase64(credentialCreateOptions.challenge.toString());
return {
...credentialCreateOptions,
challenge,
user,
};
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
*
* @param {PublicKeyCredential} newAssertion
* @returns {Assertion}
*/
export function transformNewAssertionForServer(newAssertion) {
const response = /** @type {AuthenticatorAttestationResponse} */ (newAssertion.response);
const attObj = new Uint8Array(response.attestationObject);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: encodeBase64(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: encodeBase64(clientDataJSON),
attestationObject: encodeBase64(attObj),
},
};
}
/**
* Transforms the items in the credentialRequestOptions generated on the server
*
* @param {PublicKeyCredentialRequestOptions} credentialRequestOptions
* @returns {PublicKeyCredentialRequestOptions}
*/
export function transformCredentialRequestOptions(credentialRequestOptions) {
const challenge = decodeBase64(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = decodeBase64(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return Object.assign({}, credentialRequestOptions, {
challenge,
allowCredentials,
});
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
* @returns {AuthAssertion}
*/
export function transformAssertionForServer(newAssertion) {
const response = /** @type {AuthenticatorAssertionResponse} */ (newAssertion.response);
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: encodeBase64(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: encodeBase64Raw(clientDataJSON),
signature: encodeBase64Raw(sig),
authenticatorData: encodeBase64Raw(authData),
userHandle: null,
},
};
}

View File

@ -48,9 +48,6 @@ export default [
"lit/no-template-bind": "error",
"no-unused-vars": "off",
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
// TODO: TypeScript already handles this.
// Remove after project-wide ESLint config is properly set up.
"no-undef": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": [
"error",
@ -74,18 +71,8 @@ export default [
...globals.node,
},
},
files: [
// TODO:Remove after project-wide ESLint config is properly set up.
"scripts/**/*.mjs",
"authentication/**/*.js",
"sfe/**/*.js",
"*.ts",
"*.mjs",
],
files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
rules: {
"no-undef": "off",
// TODO: TypeScript already handles this.
// Remove after project-wide ESLint config is properly set up.
"no-unused-vars": "off",
// We WANT our scripts to output to the console!
"no-console": "off",

1914
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -57,14 +57,9 @@
"ts-pattern": "^5.4.0",
"unist-util-visit": "^5.0.0",
"webcomponent-qr-code": "^1.2.0",
"yaml": "^2.5.1",
"bootstrap": "^4.6.1",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"weakmap-polyfill": "^2.0.4"
"yaml": "^2.5.1"
},
"devDependencies": {
"@types/jquery": "^3.5.31",
"@eslint/js": "^9.11.1",
"@hcaptcha/types": "^1.0.4",
"@lit/localize-tools": "^0.8.0",
@ -95,8 +90,6 @@
"@wdio/spec-reporter": "^9.1.2",
"chromedriver": "^131.0.1",
"esbuild": "^0.25.0",
"esbuild-plugin-copy": "^2.1.1",
"esbuild-plugin-es5": "^2.1.1",
"esbuild-plugin-polyfill-node": "^0.3.0",
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
"eslint": "^9.11.1",
@ -168,12 +161,6 @@
"watch": "run-s build-locales esbuild:watch"
},
"type": "module",
"exports": {
"./package.json": "./package.json",
"./paths": "./paths.js",
"./authentication": "./authentication/index.js",
"./scripts/*": "./scripts/*.mjs"
},
"wireit": {
"build": {
"#comment": [
@ -206,7 +193,8 @@
"./dist/patternfly.min.css"
],
"dependencies": [
"build-locales"
"build-locales",
"./packages/sfe:build"
],
"env": {
"NODE_RUNNER": {
@ -216,7 +204,12 @@
}
},
"build:sfe": {
"command": "node scripts/build-sfe.mjs"
"dependencies": [
"./packages/sfe:build"
],
"files": [
"./packages/sfe/**/*.ts"
]
},
"build-proxy": {
"command": "node scripts/build-web.mjs --proxy",
@ -249,6 +242,11 @@
"lint:package"
]
},
"format:packages": {
"dependencies": [
"./packages/sfe:prettier"
]
},
"lint": {
"command": "eslint --max-warnings 0 --fix",
"env": {
@ -276,6 +274,11 @@
"shell": true,
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
},
"lint:lockfiles": {
"dependencies": [
"./packages/sfe:lint:lockfile"
]
},
"lint:package": {
"command": "syncpack format -i ' '"
},
@ -311,7 +314,9 @@
"lint:spelling",
"lint:package",
"lint:lockfile",
"lint:precommit"
"lint:lockfiles",
"lint:precommit",
"format:packages"
]
},
"prettier": {

View File

@ -0,0 +1,23 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"]
}

View File

@ -0,0 +1,18 @@
The MIT License (MIT)
Copyright (c) 2024 Authentik Security, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,68 @@
{
"name": "@goauthentik/web-sfe",
"version": "0.0.0",
"dependencies": {
"@goauthentik/api": "^2024.6.0-1719577139",
"base64-js": "^1.5.1",
"bootstrap": "^4.6.1",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"weakmap-polyfill": "^2.0.4"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-swc": "^0.4.0",
"@swc/cli": "^0.4.0",
"@swc/core": "^1.7.28",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/jquery": "^3.5.31",
"lockfile-lint": "^4.14.0",
"prettier": "^3.3.2",
"rollup": "^4.23.0",
"rollup-plugin-copy": "^3.5.0",
"wireit": "^0.14.9"
},
"license": "MIT",
"optionalDependencies": {
"@swc/core": "^1.7.28",
"@swc/core-darwin-arm64": "^1.6.13",
"@swc/core-darwin-x64": "^1.6.13",
"@swc/core-linux-arm-gnueabihf": "^1.6.13",
"@swc/core-linux-arm64-gnu": "^1.6.13",
"@swc/core-linux-arm64-musl": "^1.6.13",
"@swc/core-linux-x64-gnu": "^1.6.13",
"@swc/core-linux-x64-musl": "^1.6.13",
"@swc/core-win32-arm64-msvc": "^1.6.13",
"@swc/core-win32-ia32-msvc": "^1.6.13",
"@swc/core-win32-x64-msvc": "^1.6.13"
},
"private": true,
"scripts": {
"build": "wireit",
"lint:lockfile": "wireit",
"prettier": "prettier --write ./src ./tsconfig.json ./rollup.config.js ./package.json",
"watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
},
"wireit": {
"build:sfe": {
"command": "rollup -c rollup.config.js --bundleConfigAsCjs",
"files": [
"../../node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/index.ts"
],
"output": [
"./dist/sfe/*"
]
},
"build": {
"command": "mkdir -p ../../dist/sfe && cp -r dist/sfe/* ../../dist/sfe",
"dependencies": [
"build:sfe"
]
},
"lint:lockfile": {
"command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
}
}
}

View File

@ -0,0 +1,43 @@
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import swc from "@rollup/plugin-swc";
import copy from "rollup-plugin-copy";
export default {
input: "src/index.ts",
output: {
dir: "./dist/sfe",
format: "cjs",
},
context: "window",
plugins: [
copy({
targets: [
{
src: "../../node_modules/bootstrap/dist/css/bootstrap.min.css",
dest: "./dist/sfe",
},
],
}),
resolve({ browser: true }),
commonjs(),
swc({
swc: {
jsc: {
loose: false,
externalHelpers: false,
// Requires v1.2.50 or upper and requires target to be es2016 or upper.
keepClassNames: false,
},
minify: false,
env: {
targets: {
edge: "17",
ie: "11",
},
mode: "entry",
},
},
}),
],
};

View File

@ -0,0 +1,527 @@
import { fromByteArray } from "base64-js";
import "formdata-polyfill";
import $ from "jquery";
import "weakmap-polyfill";
import {
type AuthenticatorValidationChallenge,
type AutosubmitChallenge,
type ChallengeTypes,
ChallengeTypesFromJSON,
type ContextualFlowInfo,
type DeviceChallenge,
type ErrorDetail,
type IdentificationChallenge,
type PasswordChallenge,
type RedirectChallenge,
} from "@goauthentik/api";
interface GlobalAuthentik {
brand: {
branding_logo: string;
};
api: {
base: string;
};
}
function ak(): GlobalAuthentik {
return (
window as unknown as {
authentik: GlobalAuthentik;
}
).authentik;
}
class SimpleFlowExecutor {
challenge?: ChallengeTypes;
flowSlug: string;
container: HTMLDivElement;
constructor(container: HTMLDivElement) {
this.flowSlug = window.location.pathname.split("/")[3];
this.container = container;
}
get apiURL() {
return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
}
start() {
$.ajax({
type: "GET",
url: this.apiURL,
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
});
}
submit(data: { [key: string]: unknown } | FormData) {
$("button[type=submit]").addClass("disabled")
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span role="status">Loading...</span>`);
let finalData: { [key: string]: unknown } = {};
if (data instanceof FormData) {
finalData = {};
data.forEach((value, key) => {
finalData[key] = value;
});
} else {
finalData = data;
}
$.ajax({
type: "POST",
url: this.apiURL,
data: JSON.stringify(finalData),
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
contentType: "application/json",
dataType: "json",
});
}
renderChallenge() {
switch (this.challenge?.component) {
case "ak-stage-identification":
new IdentificationStage(this, this.challenge).render();
return;
case "ak-stage-password":
new PasswordStage(this, this.challenge).render();
return;
case "xak-flow-redirect":
new RedirectStage(this, this.challenge).render();
return;
case "ak-stage-autosubmit":
new AutosubmitStage(this, this.challenge).render();
return;
case "ak-stage-authenticator-validate":
new AuthenticatorValidateStage(this, this.challenge).render();
return;
default:
this.container.innerText = "Unsupported stage: " + this.challenge?.component;
return;
}
}
}
export interface FlowInfoChallenge {
flowInfo?: ContextualFlowInfo;
responseErrors?: {
[key: string]: Array<ErrorDetail>;
};
}
class Stage<T extends FlowInfoChallenge> {
constructor(
public executor: SimpleFlowExecutor,
public challenge: T,
) {}
error(fieldName: string) {
if (!this.challenge.responseErrors) {
return [];
}
return this.challenge.responseErrors[fieldName] || [];
}
renderInputError(fieldName: string) {
return `${this.error(fieldName)
.map((error) => {
return `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
}
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
}
html(html: string) {
this.executor.container.innerHTML = html;
}
render() {
throw new Error("Abstract method");
}
}
const IS_INVALID = "is-invalid";
class IdentificationStage extends Stage<IdentificationChallenge> {
render() {
this.html(`
<form id="ident-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
this.challenge.applicationPre
? `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
</div>
${
this.challenge.passwordFields
? `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
class PasswordStage extends Stage<PasswordChallenge> {
render() {
this.html(`
<form id="password-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
class RedirectStage extends Stage<RedirectChallenge> {
render() {
window.location.assign(this.challenge.to);
}
}
class AutosubmitStage extends Stage<AutosubmitChallenge> {
render() {
this.html(`
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
$("#autosubmit-form").submit();
}
}
export interface Assertion {
id: string;
rawId: string;
type: string;
registrationClientExtensions: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
}
export interface AuthAssertion {
id: string;
rawId: string;
type: string;
assertionClientExtensions: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
}
class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> {
deviceChallenge?: DeviceChallenge;
b64enc(buf: Uint8Array): string {
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
b64RawEnc(buf: Uint8Array): string {
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
}
u8arr(input: string): Uint8Array {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
checkWebAuthnSupport(): boolean {
if ("credentials" in navigator) {
return true;
}
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
return false;
}
console.warn("WebAuthn not supported by browser.");
return false;
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
*/
transformCredentialCreateOptions(
credentialCreateOptions: PublicKeyCredentialCreationOptions,
userId: string,
): PublicKeyCredentialCreationOptions {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userId));
user.id = this.u8arr(this.b64enc(this.u8arr(stringId)));
const challenge = this.u8arr(credentialCreateOptions.challenge.toString());
return Object.assign({}, credentialCreateOptions, {
challenge,
user,
});
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
const attObj = new Uint8Array(
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: this.b64enc(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: this.b64enc(clientDataJSON),
attestationObject: this.b64enc(attObj),
},
};
}
transformCredentialRequestOptions(
credentialRequestOptions: PublicKeyCredentialRequestOptions,
): PublicKeyCredentialRequestOptions {
const challenge = this.u8arr(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = this.u8arr(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return Object.assign({}, credentialRequestOptions, {
challenge,
allowCredentials,
});
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
const response = newAssertion.response as AuthenticatorAssertionResponse;
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: this.b64enc(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: this.b64RawEnc(clientDataJSON),
signature: this.b64RawEnc(sig),
authenticatorData: this.b64RawEnc(authData),
userHandle: null,
},
};
}
render() {
if (!this.deviceChallenge) {
return this.renderChallengePicker();
}
switch (this.deviceChallenge.deviceClass) {
case "static":
case "totp":
this.renderCodeInput();
break;
case "webauthn":
this.renderWebauthn();
break;
default:
break;
}
}
renderChallengePicker() {
const challenges = this.challenge.deviceChallenges.filter((challenge) =>
challenge.deviceClass === "webauthn" && !this.checkWebAuthnSupport()
? undefined
: challenge,
);
this.html(`<form id="picker-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
challenges.length > 0
? "<p>Select an authentication method.</p>"
: `
<p>No compatible authentication method available</p>
`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) {
return "";
}
return `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
}
renderCodeInput() {
this.html(`
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? IS_INVALID : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
renderWebauthn() {
this.html(`
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>
`);
navigator.credentials
.get({
publicKey: this.transformCredentialRequestOptions(
this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions,
),
})
.then((assertion) => {
if (!assertion) {
throw new Error("No assertion");
}
try {
// we now have an authentication assertion! encode the byte arrays contained
// in the assertion data as strings for posting to the server
const transformedAssertionForServer = this.transformAssertionForServer(
assertion as PublicKeyCredential,
);
// post the assertion to the server for verification.
this.executor.submit({
webauthn: transformedAssertionForServer,
});
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
})
.catch((error) => {
console.warn(error);
this.deviceChallenge = undefined;
this.render();
});
}
}
const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement);
sfe.start();

View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"types": ["jquery"],
"esModuleInterop": true,
"lib": ["DOM", "ES2015", "ES2017"]
}
}

View File

@ -1,25 +0,0 @@
/**
* @file Path constants for the web package.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* @typedef {'@goauthentik/web'} WebPackageIdentifier
*/
/**
* The root of the web package.
*/
export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(__dirname));
/**
* Path to the web package's distribution directory.
*
* This is where the built files are located after running the build process.
*/
export const DistDirectory = /** @type {`${WebPackageIdentifier}/dist`} */ (
resolve(__dirname, "dist")
);

View File

@ -1,90 +0,0 @@
/**
* @file Build script for the simplified flow executor (SFE).
*/
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
import esbuild from "esbuild";
import copy from "esbuild-plugin-copy";
import { es5Plugin } from "esbuild-plugin-es5";
import { createRequire } from "node:module";
import * as path from "node:path";
/**
* Builds the Simplified Flow Executor bundle.
*
* @remarks
* The output directory and file names are referenced by the backend.
* @see {@link ../../authentik/flows/templates/if/flow-sfe.html}
* @returns {Promise<void>}
*/
async function buildSFE() {
const require = createRequire(import.meta.url);
const sourceDirectory = path.join(PackageRoot, "sfe");
const entryPoint = path.join(sourceDirectory, "main.js");
const outDirectory = path.join(DistDirectory, "sfe");
const bootstrapCSSPath = require.resolve(
path.join("bootstrap", "dist", "css", "bootstrap.min.css"),
);
/**
* @type {esbuild.BuildOptions}
*/
const config = {
tsconfig: path.join(sourceDirectory, "tsconfig.json"),
entryPoints: [entryPoint],
minify: false,
bundle: true,
sourcemap: true,
treeShaking: true,
legalComments: "external",
platform: "browser",
format: "iife",
alias: {
"@swc/helpers": path.dirname(require.resolve("@swc/helpers/package.json")),
},
banner: {
js: [
// ---
"// Simplified Flow Executor (SFE)",
`// Bundled on ${new Date().toISOString()}`,
"// @ts-nocheck",
"",
].join("\n"),
},
plugins: [
copy({
assets: [
{
from: bootstrapCSSPath,
to: outDirectory,
},
],
}),
es5Plugin({
swc: {
jsc: {
loose: false,
externalHelpers: false,
keepClassNames: false,
},
minify: false,
},
}),
],
target: ["es5"],
outdir: outDirectory,
};
esbuild.build(config);
}
buildSFE()
.then(() => {
console.log("Build complete");
})
.catch((error) => {
console.error("Build failed", error);
process.exit(1);
});

View File

@ -1,4 +1,3 @@
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
import { execFileSync } from "child_process";
import { deepmerge } from "deepmerge-ts";
import esbuild from "esbuild";
@ -171,7 +170,7 @@ function composeVersionID() {
* @throws {Error} on build failure
*/
function createEntryPointOptions([source, dest], overrides = {}) {
const outdir = path.join(DistDirectory, dest);
const outdir = path.join(__dirname, "..", "dist", dest);
/**
* @type {esbuild.BuildOptions}
@ -234,7 +233,7 @@ async function doWatch() {
buildObserverPlugin({
serverURL,
logPrefix: entryPoint[1],
relativeRoot: PackageRoot,
relativeRoot: path.join(__dirname, ".."),
}),
],
define: {

View File

@ -1,191 +0,0 @@
/**
* @import { AuthenticatorValidationChallenge, DeviceChallenge } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import {
isWebAuthnSupported,
transformAssertionForServer,
transformCredentialRequestOptions,
} from "@goauthentik/web/authentication";
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
//@ts-check
/**
* @template {AuthenticatorValidationChallenge} T
* @extends {Stage<T>}
*/
export class AuthenticatorValidateStage extends Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
super(executor, challenge);
/**
* @type {DeviceChallenge | null}
*/
this.deviceChallenge = null;
}
render() {
if (!this.deviceChallenge) {
this.renderChallengePicker();
return;
}
switch (this.deviceChallenge.deviceClass) {
case "static":
case "totp":
this.renderCodeInput();
break;
case "webauthn":
this.renderWebauthn();
break;
default:
break;
}
}
/**
* @private
*/
renderChallengePicker() {
const challenges = this.challenge.deviceChallenges.filter((challenge) =>
challenge.deviceClass === "webauthn" && !isWebAuthnSupported() ? undefined : challenge,
);
this.html(/* html */ `<form id="picker-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
challenges.length > 0
? /* html */ `<p>Select an authentication method.</p>`
: /* html */ `<p>No compatible authentication method available</p>`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) return "";
return /* html */ `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
}
/**
* @private
*/
renderCodeInput() {
this.html(/* html */ `
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
/**
* @private
*/
renderWebauthn() {
this.html(/* html */ `
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>
`);
const challenge = /** @type {PublicKeyCredentialRequestOptions} */ (
this.deviceChallenge?.challenge
);
navigator.credentials
.get({
publicKey: transformCredentialRequestOptions(challenge),
})
.then((credential) => {
if (!credential) {
throw new Error("No assertion");
}
if (credential.type !== "public-key") {
throw new Error("Invalid assertion type");
}
try {
// We now have an authentication assertion!
// Encode the byte arrays contained in the assertion data as strings
// for posting to the server.
const transformedAssertionForServer = transformAssertionForServer(
/** @type {PublicKeyCredential} */ (credential),
);
// Post the assertion to the server for verification.
this.executor.submit({
webauthn: transformedAssertionForServer,
});
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
})
.catch((error) => {
console.warn(error);
this.deviceChallenge = null;
this.render();
});
}
}

View File

@ -1,35 +0,0 @@
/**
* @import { AutosubmitChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {AutosubmitChallenge} T
* @extends {Stage<T>}
*/
export class AutosubmitStage extends Stage {
render() {
this.html(/* html */ `
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return /* html */ `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
$("#autosubmit-form").submit();
}
}

View File

@ -1,50 +0,0 @@
/**
* @import { IdentificationChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {IdentificationChallenge} T
* @extends {Stage<T>}
*/
export class IdentificationStage extends Stage {
render() {
this.html(/* html */ `
<form id="ident-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
this.challenge.applicationPre
? /* html */ `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
</div>
${
this.challenge.passwordFields
? /* html */ `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -1,37 +0,0 @@
/**
* @import { PasswordChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {PasswordChallenge} T
* @extends {Stage<T>}
*/
export class PasswordStage extends Stage {
render() {
this.html(/* html */ `
<form id="password-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -1,14 +0,0 @@
/**
* @import { RedirectChallenge } from "@goauthentik/api";
*/
import { Stage } from "./Stage.js";
/**
* @template {RedirectChallenge} T
* @extends {Stage<T>}
*/
export class RedirectStage extends Stage {
render() {
window.location.assign(this.challenge.to);
}
}

View File

@ -1,113 +0,0 @@
/**
* @import { ChallengeTypes } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import $ from "jquery";
import { ChallengeTypesFromJSON } from "@goauthentik/api";
import { AuthenticatorValidateStage } from "./AuthenticatorValidateStage.js";
import { AutosubmitStage } from "./AutosubmitStage.js";
import { IdentificationStage } from "./IdentificationStage.js";
import { PasswordStage } from "./PasswordStage.js";
import { RedirectStage } from "./RedirectStage.js";
import { ak } from "./utils.js";
/**
* Simple Flow Executor lifecycle.
*
* @implements {FlowExecutor}
*/
export class SimpleFlowExecutor {
/**
*
* @param {HTMLDivElement} container
*/
constructor(container) {
/**
* @type {ChallengeTypes | null} The current challenge.
*/
this.challenge = null;
/**
* @type {string} The flow slug.
*/
this.flowSlug = window.location.pathname.split("/")[3] || "";
/**
* @type {HTMLDivElement} The container element for the flow executor.
*/
this.container = container;
}
get apiURL() {
return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
}
start() {
$.ajax({
type: "GET",
url: this.apiURL,
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
});
}
/**
* Submits the form data.
* @param {Record<string, unknown> | FormData} payload
*/
submit(payload) {
$("button[type=submit]").addClass("disabled")
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span role="status">Loading...</span>`);
/**
* @type {Record<string, unknown>}
*/
let finalData;
if (payload instanceof FormData) {
finalData = {};
payload.forEach((value, key) => {
finalData[key] = value;
});
} else {
finalData = payload;
}
$.ajax({
type: "POST",
url: this.apiURL,
data: JSON.stringify(finalData),
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
contentType: "application/json",
dataType: "json",
});
}
/**
* @returns {void}
*/
renderChallenge() {
switch (this.challenge?.component) {
case "ak-stage-identification":
return new IdentificationStage(this, this.challenge).render();
case "ak-stage-password":
return new PasswordStage(this, this.challenge).render();
case "xak-flow-redirect":
return new RedirectStage(this, this.challenge).render();
case "ak-stage-autosubmit":
return new AutosubmitStage(this, this.challenge).render();
case "ak-stage-authenticator-validate":
return new AuthenticatorValidateStage(this, this.challenge).render();
default:
this.container.innerText = `Unsupported stage: ${this.challenge?.component}`;
return;
}
}
}

View File

@ -1,116 +0,0 @@
/**
* @import { ContextualFlowInfo, ErrorDetail } from "@goauthentik/api";
*/
/**
* @typedef {object} FlowInfoChallenge
* @property {ContextualFlowInfo} [flowInfo]
* @property {Record<string, Array<ErrorDetail>>} [responseErrors]
*/
/**
* @abstract
*/
export class FlowExecutor {
constructor() {
/**
* The DOM container element.
*
* @type {HTMLElement}
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.container;
}
/**
* Submits the form data.
*
* @param {Record<string, unknown> | FormData} data The data to submit.
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
submit(data) {
throw new Error(`Method 'submit' not implemented in ${this.constructor.name}`);
}
}
/**
* Represents a stage in a flow
* @template {FlowInfoChallenge} T
* @abstract
*/
export class Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
/** @type {FlowExecutor} */
this.executor = executor;
/** @type {T} */
this.challenge = challenge;
}
/**
* @protected
* @param {string} fieldName
*/
error(fieldName) {
if (!this.challenge.responseErrors) {
return [];
}
return this.challenge.responseErrors[fieldName] || [];
}
/**
* @protected
* @param {string} fieldName
* @returns {string}
*/
renderInputError(fieldName) {
return `${this.error(fieldName)
.map((error) => {
return /* html */ `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @returns {string}
*/
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return /* html */ `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @param {string} innerHTML
* @returns {void}
*/
html(innerHTML) {
this.executor.container.innerHTML = innerHTML;
}
/**
* Renders the stage (must be implemented by subclasses)
*
* @abstract
* @returns {void}
*/
render() {
throw new Error("Abstract method");
}
}

View File

@ -1,12 +0,0 @@
/**
* @file Simplified Flow Executor (SFE) library module.
*/
export * from "./Stage.js";
export * from "./SimpleFlowExecutor.js";
export * from "./AuthenticatorValidateStage.js";
export * from "./AutosubmitStage.js";
export * from "./IdentificationStage.js";
export * from "./PasswordStage.js";
export * from "./RedirectStage.js";
export * from "./utils.js";

View File

@ -1,20 +0,0 @@
/**
* @typedef {object} GlobalAuthentik
* @property {object} brand
* @property {string} brand.branding_logo
* @property {object} api
* @property {string} api.base
*/
/**
* Retrieves the global authentik object from the window.
* @throws {Error} If the object not found
* @returns {GlobalAuthentik}
*/
export function ak() {
if (!("authentik" in window)) {
throw new Error("No authentik object found in window");
}
return /** @type {GlobalAuthentik} */ (window.authentik);
}

View File

@ -1,17 +0,0 @@
/**
* @file Simplified Flow Executor (SFE) entry point.
*/
import "formdata-polyfill";
import $ from "jquery";
import { SimpleFlowExecutor } from "./lib/index.js";
const flowContainer = /** @type {HTMLDivElement} */ ($("#flow-sfe-container")[0]);
if (!flowContainer) {
throw new Error("No flow container element found");
}
const sfe = new SimpleFlowExecutor(flowContainer);
sfe.start();

View File

@ -1,46 +0,0 @@
{
// TODO: Replace with @goauthentik/tsconfig after project compilation.
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"paths": {
"@goauthentik/web/authentication": ["../authentication/index.js"]
},
"alwaysStrict": true,
"baseUrl": ".",
"rootDir": "../",
"composite": true,
"declaration": true,
"allowJs": true,
"declarationMap": true,
"isolatedModules": true,
"incremental": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"lib": ["DOM", "ES2015", "ES2017"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"outDir": "${configDir}/out",
"pretty": true,
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ESNext",
"useUnknownInCatchVariables": true
},
"exclude": [
// ---
"./out/**/*",
"./dist/**/*"
],
"include": [
// ---
"./**/*.js",
"../authentication/**/*.js"
]
}

View File

@ -19,7 +19,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent
@customElement("ak-about-modal")
export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) {
static get styles() {
return ModalButton.styles.concat(
return super.styles.concat(
PFAbout,
css`
.pf-c-about-modal-box__hero {

View File

@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
@ -37,10 +37,10 @@ import "./AdminSidebar";
@customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface {
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
@property({ type: Boolean })
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
ws: WebsocketClient;
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
patchRouteParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
patchRouteParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
super.connectedCallback();
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
const { ESBuildObserver } = await import("src/development/build-observer");
new ESBuildObserver(process.env.WATCHER_URL);
}
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/administration/overview"
defaultURL="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>

View File

@ -6,7 +6,7 @@ import {
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers";
@ -95,62 +95,127 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
}
renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [
path: string | null,
/**
* The pathname to match against. If null, this is a parent item.
*/
pathname: string | null,
/**
* The label to display in the sidebar.
*/
label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
/**
* The attributes to apply to the sidebar item. This is a map of attribute name to value.
*
* The second attribute type is of string[] to help with the 'activeWhen' control,
* which was commonplace and singular enough to merit its own handler.
*/
attributes?: Record<string, unknown> | string[] | null,
/**
* The children of this sidebar item. This is a recursive structure.
*/
children?: SidebarEntry[],
];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[null, msg("Customization"), null, [
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")]]],
// ---
[
null,
msg("Dashboards"),
{ "?expanded": true },
[
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")],
],
],
[
null,
msg("Applications"),
null,
[
[
"/core/applications",
msg("Applications"),
[`/core/applications/:slug(${SLUG_PATTERN})`],
],
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
["/outpost/outposts", msg("Outposts")],
],
],
[
null,
msg("Events"),
null,
[
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")],
],
],
[
null,
msg("Customization"),
null,
[
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")],
],
],
[
null,
msg("Flows and Stages"),
null,
[
["/flow/flows", msg("Flows"), [`/flow/flows/:slug(${SLUG_PATTERN})`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")],
],
],
[
null,
msg("Directory"),
null,
[
["/identity/users", msg("Users"), [`/identity/users/:id(${ID_PATTERN})`]],
["/identity/groups", msg("Groups"), [`/identity/groups/:id(${UUID_PATTERN})`]],
["/identity/roles", msg("Roles"), [`/identity/roles/:id(${UUID_PATTERN})`]],
[
"/core/sources",
msg("Federation and Social login"),
[`/core/sources/:slug(${SLUG_PATTERN})`],
],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")],
],
],
[
null,
msg("System"),
null,
[
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")],
],
],
];
// Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: (attributes ?? {});
if (path) {
properties.path = path;
if (pathname) {
properties.pathname = pathname;
}
return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)}

View File

@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -55,12 +54,10 @@ export class DebugPage extends AKElement {
message: "Success",
});
})
.catch(async (error) => {
const parsedError = await parseAPIResponseError(error);
.catch((exc) => {
showMessage({
level: MessageLevel.error,
message: pluckErrorDetail(parsedError),
message: exc,
});
});
}}

View File

@ -1,155 +1,210 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { Route } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
import { html } from "lit";
export const ROUTES: Route[] = [
interface IDParameters {
id: string;
}
interface SlugParameters {
slug: string;
}
interface UUIDParameters {
uuid: string;
}
export const ROUTES = [
// Prevent infinite Shell loops
new Route(new RegExp("^/$")).redirect("/administration/overview"),
new Route(new RegExp("^#.*")).redirect("/administration/overview"),
new Route(new RegExp("^/library$")).redirect("/if/user/", true),
Route.redirect("^/$", "/administration/overview"),
Route.redirect("^#.*", "/administration/overview"),
Route.redirect("^/library$", "/if/user/", true),
// statically imported since this is the default route
new Route(new RegExp("^/administration/overview$"), async () => {
new Route("/administration/overview", () => {
return html`<ak-admin-overview></ak-admin-overview>`;
}),
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
new Route("/administration/dashboard/users", async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
}),
new Route(new RegExp("^/administration/system-tasks$"), async () => {
new Route("/administration/system-tasks", async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`;
}),
new Route(new RegExp("^/core/providers$"), async () => {
new Route("/core/providers", async () => {
await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`;
}),
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
}),
new Route(new RegExp("^/core/applications$"), async () => {
new Route<IDParameters>(
new URLPattern({
pathname: `/core/providers/:id(${ID_PATTERN})`,
}),
async (params) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view
.providerID=${parseInt(params.id, 10)}
></ak-provider-view>`;
},
),
new Route("/core/applications", async () => {
await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`;
}),
new Route(new RegExp(`^/core/applications/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route(`/core/applications/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
}),
new Route(new RegExp("^/core/sources$"), async () => {
new Route("/core/sources", async () => {
await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`;
}),
new Route(new RegExp(`^/core/sources/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route(`/core/sources/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
}),
new Route(new RegExp("^/core/property-mappings$"), async () => {
new Route("/core/property-mappings", async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`;
}),
new Route(new RegExp("^/core/tokens$"), async () => {
new Route("/core/tokens", async () => {
await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`;
}),
new Route(new RegExp("^/core/brands"), async () => {
new Route("/core/brands", async () => {
await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`;
}),
new Route(new RegExp("^/policy/policies$"), async () => {
new Route("/policy/policies", async () => {
await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`;
}),
new Route(new RegExp("^/policy/reputation$"), async () => {
new Route("/policy/reputation", async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
}),
new Route(new RegExp("^/identity/groups$"), async () => {
new Route("/identity/groups", async () => {
await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`;
}),
new Route(new RegExp(`^/identity/groups/(?<uuid>${UUID_REGEX})$`), async (args) => {
new Route<UUIDParameters>(`/identity/groups/:uuid(${UUID_PATTERN})`, async ({ uuid }) => {
await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
}),
new Route(new RegExp("^/identity/users$"), async () => {
new Route("/identity/users", async () => {
await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`;
}),
new Route(new RegExp(`^/identity/users/(?<id>${ID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/identity/users/:id(${ID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
}),
new Route(new RegExp("^/identity/roles$"), async () => {
new Route("/identity/roles", async () => {
await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`;
}),
new Route(new RegExp(`^/identity/roles/(?<id>${UUID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/identity/roles/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
return html`<ak-role-view roleId=${id}></ak-role-view>`;
}),
new Route(new RegExp("^/flow/stages/invitations$"), async () => {
new Route("/flow/stages/invitations", async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
}),
new Route(new RegExp("^/flow/stages/prompts$"), async () => {
new Route("/flow/stages/prompts", async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
}),
new Route(new RegExp("^/flow/stages$"), async () => {
new Route("/flow/stages", async () => {
await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`;
}),
new Route(new RegExp("^/flow/flows$"), async () => {
new Route("/flow/flows", async () => {
await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`;
}),
new Route(new RegExp(`^/flow/flows/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route<SlugParameters>(`/flow/flows/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
}),
new Route(new RegExp("^/events/log$"), async () => {
new Route("/events/log", async () => {
await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`;
}),
new Route(new RegExp(`^/events/log/(?<id>${UUID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/events/log/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
}),
new Route(new RegExp("^/events/transports$"), async () => {
new Route("/events/transports", async () => {
await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`;
}),
new Route(new RegExp("^/events/rules$"), async () => {
new Route("/events/rules", async () => {
await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`;
}),
new Route(new RegExp("^/outpost/outposts$"), async () => {
new Route("/outpost/outposts", async () => {
await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
}),
new Route(new RegExp("^/outpost/integrations$"), async () => {
new Route("/outpost/integrations", async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
}),
new Route(new RegExp("^/crypto/certificates$"), async () => {
new Route("/crypto/certificates", async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}),
new Route(new RegExp("^/admin/settings$"), async () => {
new Route("/admin/settings", async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`;
}),
new Route(new RegExp("^/blueprints/instances$"), async () => {
new Route("/blueprints/instances", async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
new Route(new RegExp("^/debug$"), async () => {
new Route("/debug", async () => {
await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`;
}),
new Route(new RegExp("^/enterprise/licenses$"), async () => {
new Route("/enterprise/licenses", async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}),
];
] satisfies Route<never>[];

View File

@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { formatRouteHash } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -79,10 +79,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
}
quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
];
@ -195,10 +198,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
];

View File

@ -1,16 +1,13 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
import { msg } from "@lit/localize";
import { PropertyValues, TemplateResult, html, nothing } from "lit";
import { state } from "lit/decorators.js";
import { ResponseError } from "@goauthentik/api";
export interface AdminStatus {
icon: string;
message?: TemplateResult;
@ -32,7 +29,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
// Current error state if any request fails
@state()
protected error?: APIError;
protected error?: string;
// Abstract methods to be implemented by subclasses
abstract getPrimaryValue(): Promise<T>;
@ -62,9 +59,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.value = value; // Triggers shouldUpdate
this.error = undefined;
})
.catch(async (error: unknown) => {
.catch((err: ResponseError) => {
this.status = undefined;
this.error = await parseAPIResponseError(error);
this.error = err?.response?.statusText ?? msg("Unknown error");
});
}
@ -82,9 +79,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status = status;
this.error = undefined;
})
.catch(async (error: unknown) => {
.catch((err: ResponseError) => {
this.status = undefined;
this.error = await parseAPIResponseError(error);
this.error = err?.response?.statusText ?? msg("Unknown error");
});
// Prevent immediate re-render if only value changed
@ -123,8 +120,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
*/
private renderError(error: string): TemplateResult {
return html`
<p><i class="fa fa-times"></i>&nbsp;${msg("Failed to fetch")}</p>
<p class="subtext">${error}</p>
<p><i class="fa fa-times"></i>&nbsp;${error}</p>
<p class="subtext">${msg("Failed to fetch")}</p>
`;
}
@ -149,7 +146,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status
? this.renderStatus(this.status) // Status available
: this.error
? this.renderError(pluckErrorDetail(this.error)) // Error state
? this.renderError(this.error) // Error state
: this.renderLoading() // Loading state
}
</p>

View File

@ -10,7 +10,6 @@ import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
@ -69,7 +68,7 @@ export class RecentEventsCard extends Table<Event> {
</div>`;
}
row(item: EventWithContext): SlottedTemplateResult[] {
row(item: EventWithContext): TemplateResult[] {
return [
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
<small>${item.app}</small>`,
@ -82,11 +81,7 @@ export class RecentEventsCard extends Table<Event> {
];
}
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
if (this.error) {
return super.renderEmpty(inner);
}
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state header=${msg("No Events found.")}>
<div slot="body">${msg("No matching events could be found.")}</div>

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -71,7 +71,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
static get styles(): CSSResult[] {
return TablePage.styles.concat(PFCard, applicationListStyle);
return super.styles.concat(PFCard, applicationListStyle);
}
columns(): TableColumn[] {
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
${msg("Create with Provider")}
</button>
</ak-application-wizard>
<ak-forms-modal .open=${getURLParam("createForm", false)}>
<ak-forms-modal .open=${getRouteParameter("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Application")} </span>
<ak-application-form slot="form"> </ak-application-form>

View File

@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Label";
import "@goauthentik/elements/buttons/ActionButton/ak-action-button";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { css, html } from "lit";
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p>
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"

View File

@ -1,7 +1,7 @@
import { styles } from "@goauthentik/admin/applications/wizard/ApplicationWizardFormStepStyles.css.js";
import { WizardStep } from "@goauthentik/components/ak-wizard/WizardStep.js";
import {
NavigationEventInit,
NavigationUpdate,
WizardNavigationEvent,
WizardUpdateEvent,
} from "@goauthentik/components/ak-wizard/events";
@ -14,9 +14,9 @@ import { property, query } from "lit/decorators.js";
import { ValidationError } from "@goauthentik/api";
import {
ApplicationTransactionValidationError,
type ApplicationWizardState,
type ApplicationWizardStateUpdate,
ExtendedValidationError,
} from "./types";
export class ApplicationWizardStep extends WizardStep {
@ -48,7 +48,7 @@ export class ApplicationWizardStep extends WizardStep {
}
protected removeErrors(
keyToDelete: keyof ApplicationTransactionValidationError,
keyToDelete: keyof ExtendedValidationError,
): ValidationError | undefined {
if (!this.wizard.errors) {
return undefined;
@ -71,7 +71,7 @@ export class ApplicationWizardStep extends WizardStep {
public handleUpdate(
update?: ApplicationWizardStateUpdate,
destination?: string,
enable?: NavigationEventInit,
enable?: NavigationUpdate,
) {
// Inform ApplicationWizard of content state
if (update) {

View File

@ -1,7 +1,6 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
@ -11,6 +10,7 @@ import { type NavigableButton, type WizardButton } from "@goauthentik/components
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router";
import { msg } from "@lit/localize";
import { html } from "lit";

View File

@ -1,10 +1,9 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { parseAPIError } from "@goauthentik/common/errors";
import { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js";
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { showAPIErrorMessage } from "@goauthentik/elements/messages/MessageContainer";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { P, match } from "ts-pattern";
@ -31,11 +30,10 @@ import {
type TransactionApplicationRequest,
type TransactionApplicationResponse,
type TransactionPolicyBindingRequest,
instanceOfValidationError,
} from "@goauthentik/api";
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
import { OneOfProvider, isApplicationTransactionValidationError } from "../types.js";
import { ExtendedValidationError, OneOfProvider } from "../types.js";
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
const _submitStates = ["reviewing", "running", "submitted"] as const;
@ -133,46 +131,39 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
this.state = "running";
return new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({
transactionApplicationRequest: request,
})
.then((_response: TransactionApplicationResponse) => {
this.dispatchCustomEvent(EVENT_REFRESH);
this.state = "submitted";
})
return (
new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({
transactionApplicationRequest: request,
})
.then((_response: TransactionApplicationResponse) => {
this.dispatchCustomEvent(EVENT_REFRESH);
this.state = "submitted";
})
.catch(async (error) => {
const parsedError = await parseAPIResponseError(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch(async (resolution: any) => {
const errors = (await parseAPIError(
await resolution,
)) as ExtendedValidationError;
if (!instanceOfValidationError(parsedError)) {
showAPIErrorMessage(parsedError);
return;
}
if (isApplicationTransactionValidationError(parsedError)) {
// THIS is a really gross special case; if the user is duplicating the name of an existing provider, the error appears on the `app` (!) error object.
// We have to move that to the `provider.name` error field so it shows up in the right place.
if (Array.isArray(parsedError.app?.provider)) {
const providerError = parsedError.app.provider;
parsedError.provider = {
...parsedError.provider,
name: providerError,
};
delete parsedError.app.provider;
if (Object.keys(parsedError.app).length === 0) {
delete parsedError.app;
// THIS is a really gross special case; if the user is duplicating the name of
// an existing provider, the error appears on the `app` (!) error object. We
// have to move that to the `provider.name` error field so it shows up in the
// right place.
if (Array.isArray(errors?.app?.provider)) {
const providerError = errors.app.provider;
errors.provider = errors.provider ?? {};
errors.provider.name = providerError;
delete errors.app.provider;
if (Object.keys(errors.app).length === 0) {
delete errors.app;
}
}
}
this.handleUpdate({ errors: parsedError });
this.state = "reviewing";
});
this.handleUpdate({ errors });
this.state = "reviewing";
})
);
}
override handleButton(button: WizardButton) {
@ -234,20 +225,22 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
}
renderError() {
const { errors } = this.wizard;
if (Object.keys(errors).length === 0) return nothing;
if (Object.keys(this.wizard.errors).length === 0) {
return nothing;
}
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
const errors = this.wizard.errors;
return html` <hr class="pf-c-divider" />
${match(errors)
${match(errors as ExtendedValidationError)
.with(
{ app: P.nonNullable },
() =>
html`<p>${msg("There was an error in the application.")}</p>
<p>
<a @click=${WizardNavigationEvent.toListener(this, "application")}>
${msg("Review the application.")}
</a>
<a @click=${navTo("application")}
>${msg("Review the application.")}</a
>
</p>`,
)
.with(
@ -255,20 +248,13 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
() =>
html`<p>${msg("There was an error in the provider.")}</p>
<p>
<a @click=${WizardNavigationEvent.toListener(this, "provider")}
>${msg("Review the provider.")}</a
>
<a @click=${navTo("provider")}>${msg("Review the provider.")}</a>
</p>`,
)
.with(
{ detail: P.nonNullable },
() =>
html`<p>
${msg(
"There was an error. Please go back and review the application.",
)}:
${errors.detail}
</p>`,
`<p>${msg("There was an error. Please go back and review the application.")}: ${errors.detail}</p>`,
)
.with(
{
@ -278,7 +264,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
html`<p>${msg("There was an error:")}:</p>
<ul>
${(errors.nonFieldErrors ?? []).map(
(reason) => html`<li>${reason}</li>`,
(e: string) => html`<li>${e}</li>`,
)}
</ul>
<p>${msg("Please go back and review the application.")}</p>`,

View File

@ -9,7 +9,7 @@ import { customElement, state } from "lit/decorators.js";
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
import { ApplicationTransactionValidationError } from "../../types.js";
import { ExtendedValidationError } from "../../types.js";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-oauth")
@ -34,7 +34,7 @@ export class ApplicationWizardOauth2ProviderForm extends ApplicationWizardProvid
});
}
renderForm(provider: OAuth2Provider, errors: ApplicationTransactionValidationError) {
renderForm(provider: OAuth2Provider, errors: ExtendedValidationError) {
const showClientSecretCallback = (show: boolean) => {
this.showClientSecret = show;
};

View File

@ -25,30 +25,16 @@ export type OneOfProvider =
export type ValidationRecord = { [key: string]: string[] };
/**
* An error that occurs during the creation or modification of an application.
*
* @todo (Elf) Extend this type to include all possible errors that can occur during the creation or modification of an application.
*/
export interface ApplicationTransactionValidationError extends ValidationError {
// TODO: Elf, extend this type and apply it to every object in the wizard. Then run
// the type-checker again.
export type ExtendedValidationError = ValidationError & {
app?: ValidationRecord;
provider?: ValidationRecord;
bindings?: ValidationRecord;
detail?: unknown;
}
/**
* Type-guard to determine if an API response is shaped like an {@linkcode ApplicationTransactionValidationError}.
*/
export function isApplicationTransactionValidationError(
error: ValidationError,
): error is ApplicationTransactionValidationError {
if ("app" in error) return true;
if ("provider" in error) return true;
if ("bindings" in error) return true;
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detail?: any;
};
// We use the PolicyBinding instead of the PolicyBindingRequest here, because that gives us a slot
// in which to preserve the retrieved policy, group, or user object from the SearchSelect used to
@ -63,7 +49,7 @@ export interface ApplicationWizardState {
proxyMode: ProxyMode;
bindings: PolicyBinding[];
currentBinding: number;
errors: ValidationError | ApplicationTransactionValidationError;
errors: ExtendedValidationError;
}
export interface ApplicationWizardStateUpdate {

View File

@ -8,7 +8,6 @@ import "@goauthentik/components/ak-event-info";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize";
@ -73,7 +72,7 @@ export class EventListPage extends TablePage<Event> {
`;
}
row(item: EventWithContext): SlottedTemplateResult[] {
row(item: EventWithContext): TemplateResult[] {
return [
html`<div>${actionToLabel(item.action)}</div>
<small>${item.app}</small>`,

View File

@ -1,31 +1,27 @@
import { EventWithContext } from "@goauthentik/common/events";
import { truncate } from "@goauthentik/common/utils";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import { msg, str } from "@lit/localize";
import { html, nothing } from "lit";
import { TemplateResult, html } from "lit";
/**
* Given event with a geographical context, format it into a string for display.
*/
export function EventGeo(event: EventWithContext): SlottedTemplateResult {
if (!event.context.geo) return nothing;
const { city, country, continent } = event.context.geo;
const parts = [city, country, continent].filter(Boolean);
return html`${parts.join(", ")}`;
export function EventGeo(event: EventWithContext): TemplateResult {
let geo: KeyUnknown | undefined = undefined;
if (Object.hasOwn(event.context, "geo")) {
geo = event.context.geo as KeyUnknown;
const parts = [geo.city, geo.country, geo.continent].filter(
(v) => v !== "" && v !== undefined,
);
return html`${parts.join(", ")}`;
}
return html``;
}
export function EventUser(
event: EventWithContext,
truncateUsername?: number,
): SlottedTemplateResult {
if (!event.user.username) return html`-`;
let body: SlottedTemplateResult = nothing;
export function EventUser(event: EventWithContext, truncateUsername?: number): TemplateResult {
if (!event.user.username) {
return html`-`;
}
let body = html``;
if (event.user.is_anonymous) {
body = html`<div>${msg("Anonymous user")}</div>`;
} else {
@ -37,14 +33,12 @@ export function EventUser(
>
</div>`;
}
if (event.user.on_behalf_of) {
return html`${body}<small>
body = html`${body}<small>
<a href="#/identity/users/${event.user.on_behalf_of.pk}"
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
>
</small>`;
}
return body;
}

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/elements/events/LogViewer";
import { Form } from "@goauthentik/elements/forms/Form";

View File

@ -1,7 +1,7 @@
import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/flows/FlowImportForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ConfirmationForm";
@ -107,10 +107,9 @@ export class FlowListPage extends TablePage<Flow> {
<button
class="pf-c-button pf-m-plain"
@click=${() => {
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
const url = formatFlowURL(item);
window.open(url, "_blank");
}}
>
<pf-tooltip position="top" content=${msg("Execute")}>

View File

@ -1,11 +1,10 @@
import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram";
import "@goauthentik/admin/flows/FlowForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { DesignationToLabel, applyNextParam, formatFlowURL } from "@goauthentik/admin/flows/utils";
import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -24,7 +23,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Flow, FlowsApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
import {
Flow,
FlowsApi,
RbacPermissionsAssignedByUsersListModelEnum,
ResponseError,
} from "@goauthentik/api";
@customElement("ak-flow-view")
export class FlowViewPage extends AKElement {
@ -147,12 +151,9 @@ export class FlowViewPage extends AKElement {
<button
class="pf-c-button pf-m-block pf-m-primary"
@click=${() => {
const finalURL = `${
window.location.origin
}/if/flow/${this.flow.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
const url = formatFlowURL(this.flow);
window.open(url, "_blank");
}}
>
${msg("Normal")}
@ -164,12 +165,16 @@ export class FlowViewPage extends AKElement {
.flowsInstancesExecuteRetrieve({
slug: this.flow.slug,
})
.then((link) => {
const finalURL = `${
link.link
}${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
.then(({ link }) => {
const finalURL = URL.canParse(link)
? new URL(link)
: new URL(
link,
window.location.origin,
);
applyNextParam(finalURL);
window.open(finalURL, "_blank");
});
}}
@ -191,15 +196,13 @@ export class FlowViewPage extends AKElement {
)}`;
window.open(finalURL, "_blank");
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(
error.response.url,
"_blank",
);
}
.catch((exc: ResponseError) => {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(
exc.response.url,
"_blank",
);
});
}}
>

View File

@ -43,3 +43,51 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
return msg("Unknown layout");
}
}
/**
* Applies the next URL as a query parameter to the given URL or URLSearchParams object.
*
* @todo deprecate this once hash routing is removed.
*/
export function applyNextParam(
target: URL | URLSearchParams,
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): void {
const searchParams = target instanceof URL ? target.searchParams : target;
searchParams.set("next", destination.toString());
}
/**
* Creates a URLSearchParams object with the next URL as a query parameter.
*
* @todo deprecate this once hash routing is removed.
*/
export function createNextSearchParams(
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): URLSearchParams {
const searchParams = new URLSearchParams();
applyNextParam(searchParams, destination);
return searchParams;
}
/**
* Creates a URL to a flow, with the next URL as a query parameter.
*
* @param flow The flow to create the URL for.
* @param destination The next URL to redirect to after the flow is completed, `true` to use the current route.
*/
export function formatFlowURL(
flow: Flow,
destination: string | URL | null = window.location.pathname + "#" + window.location.hash,
): URL {
const url = new URL(`/if/flow/${flow.slug}/`, window.location.origin);
if (destination) {
applyNextParam(url, destination);
}
return url;
}

View File

@ -6,7 +6,6 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
@ -23,7 +22,7 @@ import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { UserOption } from "@goauthentik/elements/user/utils";
@ -38,7 +37,14 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api";
import {
CoreApi,
CoreUsersListTypeEnum,
Group,
ResponseError,
SessionUser,
User,
} from "@goauthentik/api";
@customElement("ak-user-related-add")
export class RelatedUserAdd extends Form<{ users: number[] }> {
@ -121,13 +127,13 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
order = "last_login";
@property({ type: Boolean })
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
@state()
me?: SessionUser;
static get styles(): CSSResult[] {
return Table.styles.concat(PFDescriptionList, PFAlert, PFBanner);
return super.styles.concat(PFDescriptionList, PFAlert, PFBanner);
}
async apiEndpoint(): Promise<PaginatedResponse<User>> {
@ -313,16 +319,14 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
description: rec.link,
});
})
.catch(async (error: unknown) => {
const parsedError =
await parseAPIResponseError(
error,
);
showMessage({
level: MessageLevel.error,
message:
pluckErrorDetail(parsedError),
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"No recovery flow is configured.",
),
});
});
});
}}
@ -462,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideServiceAccounts: this.hideServiceAccounts,
});
}}

View File

@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/forms/ProxyForm";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
order = "name";
@state()
hideManaged = getURLParam<boolean>("hideManaged", true);
hideManaged = getRouteParameter<boolean>("hideManaged", true);
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideManaged: this.hideManaged,
});
}}

View File

@ -3,7 +3,6 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { convertToSlug } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog";
import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md";
@ -21,7 +20,8 @@ import "@goauthentik/elements/ak-mdx";
import type { Replacer } from "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { formatAsSlug } from "@goauthentik/elements/router";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
(input: string): string => {
// The generated config is pretty unreliable currently so
// put it behind a flag
if (!getURLParam("generatedConfig", false)) {
if (!getRouteParameter("generatedConfig", false)) {
return input;
}
if (!this.provider) {
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
return html`<ak-tabs pageIdentifier="proxy-setup">
${servers.map((server) => {
return html`<section
slot="page-${convertToSlug(server.label)}"
slot="page-${formatAsSlug(server.label)}"
data-tab-title="${server.label}"
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
>

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { parseAPIError } from "@goauthentik/common/errors";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
@ -21,8 +21,9 @@ import {
Prompt,
PromptChallenge,
PromptTypeEnum,
ResponseError,
StagesApi,
instanceOfValidationError,
ValidationError,
} from "@goauthentik/api";
class PreviewStageHost implements StageHost {
@ -77,22 +78,15 @@ export class PromptForm extends ModelForm<Prompt, string> {
return;
}
}
return new StagesApi(DEFAULT_CONFIG)
.stagesPromptPromptsPreviewCreate({
try {
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
promptRequest: prompt,
})
.then((nextPreview) => {
this.preview = nextPreview;
this.previewError = undefined;
})
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.previewError = instanceOfValidationError(parsedError)
? parsedError.nonFieldErrors
: [pluckErrorDetail(parsedError, msg("Failed to preview prompt"))];
});
this.previewError = undefined;
} catch (exc) {
const errorMessage = parseAPIError(exc as ResponseError);
this.previewError = (errorMessage as ValidationError).nonFieldErrors;
}
}
getSuccessMessage(): string {

View File

@ -1,4 +1,3 @@
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/buttons/SpinnerButton";
import { DeleteForm } from "@goauthentik/elements/forms/DeleteForm";
@ -17,14 +16,10 @@ export class UserActiveForm extends DeleteForm {
});
}
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
onError(e: Error): void {
showMessage({
message: msg(str`Failed to update ${this.objectLabel}: ${e.toString()}`),
level: MessageLevel.error,
});
}

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { deviceTypeName } from "@goauthentik/common/labels";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/DeleteBulkForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";

View File

@ -7,10 +7,9 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { createUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
@ -24,8 +23,8 @@ import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { showAPIErrorMessage, showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -40,7 +39,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, SessionUser, User, UserPath } from "@goauthentik/api";
import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api";
export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG)
@ -58,7 +57,16 @@ export const requestRecoveryLink = (user: User) =>
}),
),
)
.catch((error: unknown) => parseAPIResponseError(error).then(showAPIErrorMessage));
.catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current brand must have a recovery flow configured to use a recovery link",
),
}),
),
);
export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
@ -109,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
activePath;
@state()
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
@state()
userPaths?: UserPath;
@ -118,13 +126,15 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
me?: SessionUser;
static get styles(): CSSResult[] {
return [...TablePage.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
}
constructor() {
super();
const defaultPath = new DefaultUIConfig().defaults.userPath;
this.activePath = getURLParam<string>("path", defaultPath);
const defaultPath = createUIConfig().defaults.userPath;
this.activePath = getRouteParameter("path", defaultPath);
uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath;
@ -135,7 +145,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()),
pathStartswith: getURLParam("path", ""),
pathStartswith: getRouteParameter("path", ""),
isActive: this.hideDeactivated ? true : undefined,
includeGroups: false,
});
@ -217,7 +227,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideDeactivated: this.hideDeactivated,
});
}}

View File

@ -79,11 +79,4 @@ export const DEFAULT_CONFIG = new Configuration({
],
});
// This is just a function so eslint doesn't complain about
// missing-whitespace-between-attributes or
// unexpected-character-in-attribute-name
export function AndNext(url: string): string {
return `?next=${encodeURIComponent(url)}`;
}
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

@ -1,5 +1,5 @@
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
import { getCookie } from "@goauthentik/common/utils";
import { getCookie } from "@goauthentik/common/http";
import {
CurrentBrand,

View File

@ -1,170 +1,60 @@
/**
* @file
* Client-side observer for ESBuild events.
* @file Client-side utilities.
*/
import type { Message as ESBuildMessage } from "esbuild";
import { TITLE_DEFAULT } from "@goauthentik/common/constants";
import { isAdminRoute } from "@goauthentik/elements/router";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
import { msg } from "@lit/localize";
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
import type { CurrentBrand } from "@goauthentik/api";
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
/**
* A client-side watcher for ESBuild.
* Create a title for the page.
*
* Note that this should be conditionally imported in your code, so that
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/client");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* }
* ```
}
* @param brand - The brand object to append to the title.
* @param segments - The segments to prepend to the title.
*/
export class ESBuildObserver extends EventSource {
/**
* Whether the watcher has a recent connection to the server.
*/
alive = true;
export function formatPageTitle(
brand: BrandTitleLike | undefined,
...segments: Array<string | undefined>
): string;
/**
* Create a title for the page.
*
* @param segments - The segments to prepend to the title.
*/
export function formatPageTitle(...segments: Array<string | undefined>): string;
/**
* Create a title for the page.
*
* @param args - The segments to prepend to the title.
* @param args - The brand object to append to the title.
*/
export function formatPageTitle(
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
): string {
const segments: string[] = [];
/**
* The number of errors that have occurred since the watcher started.
*/
errorCount = 0;
/**
* Whether a reload has been requested while offline.
*/
deferredReload = false;
/**
* The last time a message was received from the server.
*/
lastUpdatedAt = Date.now();
/**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
log("🛎️ Build completed! Reloading...");
// We use an animation frame to keep the reload from happening before the
// event loop has a chance to process the message.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
if (isAdminRoute()) {
segments.push(msg("Admin"));
}
const [arg1, ...rest] = args;
if (typeof arg1 === "object") {
const { brandingTitle = TITLE_DEFAULT } = arg1;
segments.push(brandingTitle);
} else {
segments.push(TITLE_DEFAULT);
}
for (const segment of rest) {
if (segment) {
segments.push(segment);
}
}
return segments.join(" - ");
}

View File

@ -5,7 +5,6 @@ export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.3";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";

36
web/src/common/errors.ts Normal file
View File

@ -0,0 +1,36 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
export class SentryIgnoredError extends Error {}
export class NotFoundError extends Error {}
export class RequestError extends Error {}
export type APIErrorTypes = ValidationError | GenericError;
export const HTTP_BAD_REQUEST = 400;
export const HTTP_INTERNAL_SERVICE_ERROR = 500;
export async function parseAPIError(error: Error): Promise<APIErrorTypes> {
if (!(error instanceof ResponseError)) {
return error;
}
if (
error.response.status < HTTP_BAD_REQUEST ||
error.response.status >= HTTP_INTERNAL_SERVICE_ERROR
) {
return error;
}
const body = await error.response.json();
if (error.response.status === 400) {
return ValidationErrorFromJSON(body);
}
if (error.response.status === 403) {
return GenericErrorFromJSON(body);
}
return body;
}

View File

@ -1,184 +0,0 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
//#region HTTP
/**
* Common HTTP status names used in the API and their corresponding codes.
*/
export const HTTPStatusCode = {
BadRequest: 400,
Forbidden: 403,
InternalServiceError: 500,
} as const satisfies Record<string, number>;
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
} as const;
/**
* Type guard to check if a response contains a JSON body.
*
* This is useful to guard against parsing errors when attempting to read the response body.
*/
export function isJSONResponse(response: Response): boolean {
return Boolean(response.headers.get("content-type")?.includes("application/json"));
}
//#endregion
//#region API
/**
* An API response error, typically derived from a {@linkcode Response} body.
*
* @see {@linkcode parseAPIResponseError}
*/
export type APIError = ValidationError | GenericError;
/**
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
* suitable for display to the user.
*/
export function createSyntheticGenericError(detail?: string): GenericError {
const syntheticGenericError: GenericError = {
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
};
return syntheticGenericError;
}
/**
* An error that contains a native response object.
*
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
*/
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
/**
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
*
* @see {@linkcode parseAPIResponseError} to parse the response body into a {@linkcode APIError}.
*/
export function isResponseErrorLike(errorLike: unknown): errorLike is APIErrorWithResponse {
if (!errorLike || typeof errorLike !== "object") return false;
return "response" in errorLike && errorLike.response instanceof Response;
}
/**
* A descriptor to provide a human readable error message for a given HTTP status code.
*
* @see {@linkcode ResponseErrorMessages} for a list of fallback error messages.
*/
interface ResponseErrorDescriptor {
headline: string;
reason: string;
}
/**
* Fallback error messages for HTTP status codes used when a more specific error message is not available in the response.
*/
export const ResponseErrorMessages: Record<number, ResponseErrorDescriptor> = {
[HTTPStatusCode.BadRequest]: {
headline: "Bad request",
reason: "The server did not understand the request",
},
[HTTPStatusCode.InternalServiceError]: {
headline: "Internal server error",
reason: "An unexpected error occurred",
},
} as const;
/**
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
*
* Note that this is kept separate from localization to lower the complexity of the error handling code.
*/
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
return `${descriptor.headline}: ${descriptor.reason}`;
}
/**
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
*/
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
*/
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an `Error` object.
*/
export function pluckErrorDetail(error: Error, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an error-like object.
*
* Prioritizes the `detail` key, then the `message` key.
*
*/
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
fallback ||= composeResponseErrorDescriptor(
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
);
if (!errorLike || typeof errorLike !== "object") {
return fallback;
}
if ("detail" in errorLike && typeof errorLike.detail === "string") {
return errorLike.detail;
}
if ("message" in errorLike && typeof errorLike.message === "string") {
return errorLike.message;
}
return fallback;
}
/**
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
*/
export async function parseAPIResponseError<T extends APIError = APIError>(
error: unknown,
): Promise<T> {
if (!isResponseErrorLike(error)) {
const message = error instanceof Error ? error.message : String(error);
return createSyntheticGenericError(message) as T;
}
const { response, message } = error;
if (!isJSONResponse(response)) {
return createSyntheticGenericError(message || response.statusText) as T;
}
return response
.json()
.then((body) => {
const transformer = HTTPStatusCodeTransformer[response.status];
const transformedBody = transformer ? transformer(body) : body;
return transformedBody as unknown as T;
})
.catch((transformerError: unknown) => {
console.error("Failed to parse response error body", transformerError);
return createSyntheticGenericError(message || response.statusText) as T;
});
}
//#endregion

View File

@ -8,10 +8,13 @@ export interface EventUser {
is_anonymous?: boolean;
}
export interface EventGeo {
city?: string;
country?: string;
continent?: string;
export interface EventContext {
[key: string]: EventContext | EventModel | string | number | string[];
}
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
}
export interface EventModel {
@ -25,16 +28,3 @@ export interface EventRequest {
path: string;
method: string;
}
export type EventContextProperty = EventModel | EventGeo | string | number | string[] | undefined;
// TODO: Events should have more specific types.
export interface EventContext {
[key: string]: EventContext | EventContextProperty;
geo?: EventGeo;
}
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
}

View File

@ -1,5 +1,5 @@
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { SentryIgnoredError } from "@goauthentik/common/errors";
export interface PlexPinResponse {
// Only has the fields we care about

View File

@ -0,0 +1,145 @@
import * as base64js from "base64-js";
import { msg } from "@lit/localize";
export function b64enc(buf: Uint8Array): string {
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
export function b64RawEnc(buf: Uint8Array): string {
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
}
export function u8arr(input: string): Uint8Array {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
export function checkWebAuthnSupport() {
if ("credentials" in navigator) {
return;
}
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
throw new Error(msg("WebAuthn requires this page to be accessed via HTTPS."));
}
throw new Error(msg("WebAuthn not supported by browser."));
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
*/
export function transformCredentialCreateOptions(
credentialCreateOptions: PublicKeyCredentialCreationOptions,
userId: string,
): PublicKeyCredentialCreationOptions {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userId));
user.id = u8arr(b64enc(u8arr(stringId)));
const challenge = u8arr(credentialCreateOptions.challenge.toString());
return {
...credentialCreateOptions,
challenge,
user,
};
}
export interface Assertion {
id: string;
rawId: string;
type: string;
registrationClientExtensions: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
const attObj = new Uint8Array(
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: b64enc(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: b64enc(clientDataJSON),
attestationObject: b64enc(attObj),
},
};
}
export function transformCredentialRequestOptions(
credentialRequestOptions: PublicKeyCredentialRequestOptions,
): PublicKeyCredentialRequestOptions {
const challenge = u8arr(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = u8arr(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return {
...credentialRequestOptions,
challenge,
allowCredentials,
};
}
export interface AuthAssertion {
id: string;
rawId: string;
type: string;
assertionClientExtensions: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
const response = newAssertion.response as AuthenticatorAssertionResponse;
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: b64enc(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: b64RawEnc(clientDataJSON),
signature: b64RawEnc(sig),
authenticatorData: b64RawEnc(authData),
userHandle: null,
},
};
}

28
web/src/common/http.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* @file HTTP utilities.
*/
/**
* Get the value of a cookie by its name.
*
* @param cookieName - The name of the cookie to retrieve.
* @returns The value of the cookie, or an empty string if the cookie is not found.
*/
export function getCookie(cookieName: string): string {
if (!cookieName) return "";
if (typeof document === "undefined") return "";
if (typeof document.cookie !== "string") return "";
if (!document.cookie) return "";
const search = cookieName + "=";
// Split the cookie string into individual name=value pairs...
const keyValPairs = document.cookie.split(";").map((cookie) => cookie.trim());
for (const pair of keyValPairs) {
if (!pair.startsWith(search)) continue;
return decodeURIComponent(pair.substring(search.length));
}
return "";
}

View File

@ -1,6 +1,8 @@
import { config } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
import {
ErrorEvent,
EventHint,
@ -12,11 +14,6 @@ import {
import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
/**
* A generic error that can be thrown without triggering Sentry's reporting.
*/
export class SentryIgnoredError extends Error {}
export const TAG_SENTRY_COMPONENT = "authentik.component";
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
@ -68,7 +65,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
});
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
}
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
const Spotlight = await import("@spotlightjs/spotlight");
@ -86,13 +83,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
}
return cfg;
}
// Get the interface name from URL
export function currentInterface(): string {
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
let currentInterface = "unknown";
if (pathMatches && pathMatches.length >= 2) {
currentInterface = pathMatches[1];
}
return currentInterface.toLowerCase();
}

View File

@ -1,7 +1,7 @@
import { currentInterface } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
import { isUserRoute } from "@goauthentik/elements/router";
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
import { UiThemeEnum } from "@goauthentik/api";
export enum UserDisplay {
username = "username",
@ -18,15 +18,27 @@ export enum LayoutType {
export interface UIConfig {
enabledFeatures: {
// API Request drawer in navbar
/**
* Whether to show the API request drawer in the navbar.
*/
apiDrawer: boolean;
// Notification drawer in navbar
/**
* Whether to show the notification drawer in the navbar.
*/
notificationDrawer: boolean;
// Settings in user dropdown
/**
* Whether to show the settings in the user dropdown.
*/
settings: boolean;
// Application edit in library (only shown when user is superuser)
/**
* Whether to show the application edit button in the library.
*
* This is only shown when the user is a superuser.
*/
applicationEdit: boolean;
// Search bar
/**
* Whether to show the search bar.
*/
search: boolean;
};
navbar: {
@ -38,68 +50,77 @@ export interface UIConfig {
cardBackground: string;
};
pagination: {
/**
* Number of items to show per page in paginated lists.
*/
perPage: number;
};
layout: {
/**
* Layout type to use for the application.
*/
type: LayoutType;
};
/**
* Locale to use for the application.
*/
locale: string;
/**
* Default values.
*/
defaults: {
/**
* Default path to use for user API calls.
*/
userPath: string;
};
}
export class DefaultUIConfig implements UIConfig {
enabledFeatures = {
apiDrawer: true,
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
};
layout = {
type: LayoutType.row,
};
navbar = {
userDisplay: UserDisplay.username,
};
theme = {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
};
pagination = {
perPage: 20,
};
locale = "";
defaults = {
userPath: "users",
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
const uiConfig: UIConfig = {
enabledFeatures: {
// TODO: Is the intent that only user routes should have the API drawer disabled,
// or only admin routes?
apiDrawer: !isUserRoute(),
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
},
layout: {
type: LayoutType.row,
},
navbar: {
userDisplay: UserDisplay.username,
},
theme: {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
},
pagination: {
perPage: 20,
},
locale: "",
defaults: {
userPath: "users",
},
};
constructor() {
if (currentInterface() === "user") {
this.enabledFeatures.apiDrawer = false;
}
}
// TODO: Should we deep merge the overrides instead of shallow?
Object.assign(uiConfig, overrides);
return uiConfig;
}
let globalUiConfig: Promise<UIConfig>;
export function getConfigForUser(user: UserSelf): UIConfig {
const settings = user.settings;
let config = new DefaultUIConfig();
if (!settings) {
return config;
}
config = Object.assign(new DefaultUIConfig(), settings);
return config;
}
let cachedUIConfig: UIConfig | null = null;
export function uiConfig(): Promise<UIConfig> {
if (!globalUiConfig) {
globalUiConfig = me().then((user) => {
return getConfigForUser(user.user);
});
}
return globalUiConfig;
if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
return me().then((session) => {
cachedUIConfig = createUIConfig(session.user.settings);
return cachedUIConfig;
});
}

View File

@ -1,96 +1,63 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import { CoreApi, SessionUser } from "@goauthentik/api";
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
/**
* Create a guest session for unauthenticated users.
*
* @see {@linkcode me} for the actual session retrieval.
*/
function createGuestSession(): SessionUser {
const guest: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
let globalMePromise: Promise<SessionUser> | undefined;
return guest;
}
let memoizedSession: SessionUser | null = null;
/**
* Refresh the current user session.
*/
export function refreshMe(): Promise<SessionUser> {
memoizedSession = null;
globalMePromise = undefined;
return me();
}
/**
* Retrieve the current user session.
*
* This is a memoized function, so it will only make one request per page load.
*
* @see {@linkcode refreshMe} to force a refresh.
*/
export async function me(): Promise<SessionUser> {
if (memoizedSession) return memoizedSession;
return new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve()
.then((nextSession) => {
const locale: string | undefined = nextSession.user.settings.locale;
if (locale) {
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
);
}
return nextSession;
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
const { response } = error;
if (response.status === 401 || response.status === 403) {
const { pathname, search, hash } = window.location;
const authFlowRedirectURL = new URL(
`/flows/-/default/authentication/`,
window.location.origin,
);
authFlowRedirectURL.searchParams.set("next", `${pathname}${search}${hash}`);
window.location.assign(authFlowRedirectURL);
export function me(): Promise<SessionUser> {
if (!globalMePromise) {
globalMePromise = new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve()
.then((user) => {
if (!user.user.settings || !("locale" in user.user.settings)) {
return user;
}
}
console.debug("authentik/users: Failed to retrieve user session", error);
return createGuestSession();
})
.then((nextSession) => {
memoizedSession = nextSession;
return nextSession;
});
const locale: string | undefined = user.user.settings.locale;
if (locale && locale !== "") {
console.debug(
`authentik/locale: Activating user's configured locale '${locale}'`,
);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
);
}
return user;
})
.catch((ex: ResponseError) => {
const defaultUser: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
if (ex.response?.status === 401 || ex.response?.status === 403) {
const relativeUrl = window.location
.toString()
.substring(window.location.origin.length);
window.location.assign(
`/flows/-/default/authentication/?next=${encodeURIComponent(relativeUrl)}`,
);
}
return defaultUser;
});
}
return globalMePromise;
}

View File

@ -1,36 +1,7 @@
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { CSSResult, css } from "lit";
export function getCookie(name: string): string {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export function convertToSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
export function isSlug(text: string): boolean {
const lowered = text.toLowerCase();
const forbidden = /([^\w-]|\s)/.test(lowered);
return lowered === text && !forbidden;
}
/**
* Truncate a string based on maximum word count
*/
@ -63,17 +34,29 @@ export function snakeToCamel(key: string) {
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>();
objects.forEach((obj) => {
const group = callback(obj);
if (!m.has(group)) {
m.set(group, []);
}
const tProviders = m.get(group) || [];
tProviders.push(obj);
});
return Array.from(m).sort();
}
/**
* Returns the first non-null and non-undefined argument.
*
* @deprecated Use nullish coalescing operator (??) instead.
* @remarks
*
* This needs a deeper look. Some instances of this function use `new Date()`
* which may cause issues during rendering.
*/
export function first<T>(...args: Array<T | undefined | null>): T {
for (let index = 0; index < args.length; index++) {
const element = args[index];

View File

@ -1,16 +1,10 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js";
import {
EventContext,
EventContextProperty,
EventModel,
EventWithContext,
} from "@goauthentik/common/events";
import { EventContext, EventModel, EventWithContext } from "@goauthentik/common/events";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand";
import "@goauthentik/elements/Spinner";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
@ -29,15 +23,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EventActions, FlowsApi } from "@goauthentik/api";
// TODO: Settle these types. It's too hard to make sense of what we're expecting here.
type EventSlotValueType =
| number
| SlottedTemplateResult
| undefined
| EventContext
| EventContextProperty;
type FieldLabelTuple<V extends EventSlotValueType = EventSlotValueType> = [label: string, value: V];
type Pair = [string, string | number | EventContext | EventModel | string[] | TemplateResult];
// https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/about-automation-for-issues-and-pull-requests-with-query-parameters
@ -118,7 +104,7 @@ export class EventInfo extends AKElement {
];
}
renderDescriptionGroup([term, description]: FieldLabelTuple) {
renderDescriptionGroup([term, description]: Pair) {
return html` <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${term}</span>
@ -134,7 +120,7 @@ export class EventInfo extends AKElement {
return html`<span>-</span>`;
}
const modelFields: FieldLabelTuple[] = [
const modelFields: Pair[] = [
[msg("UID"), context.pk],
[msg("Name"), context.name],
[msg("App"), context.app],
@ -148,23 +134,20 @@ export class EventInfo extends AKElement {
</div>`;
}
getEmailInfo(context: EventContext): SlottedTemplateResult {
getEmailInfo(context: EventContext): TemplateResult {
if (context === null) {
return html`<span>-</span>`;
}
const emailFields = [
// ---
// prettier-ignore
const emailFields: Pair[] = [
[msg("Message"), context.message],
[msg("Subject"), context.subject],
[msg("From"), context.from_email],
[
msg("To"),
html`${(context.to_email as string[]).map((to) => {
[msg("To"), html`${(context.to_email as string[]).map((to) => {
return html`<li>${to}</li>`;
})}`,
],
] satisfies FieldLabelTuple<EventSlotValueType>[];
})}`],
];
return html`<dl class="pf-c-description-list pf-m-horizontal">
${map(emailFields, this.renderDescriptionGroup)}

View File

@ -1,4 +1,4 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { formatAsSlug } from "@goauthentik/elements/router";
import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@ -34,7 +34,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// Do not stop propagation of this event; it must be sent up the tree so that a parent
// component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) {
this.input.value = convertToSlug(this.input.value);
this.input.value = formatAsSlug(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") {
@ -67,7 +67,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration."
const newSlug = convertToSlug(ev.target.value);
const newSlug = formatAsSlug(ev.target.value);
const oldSlug = this.input.value;
const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];

Some files were not shown because too many files have changed in this diff Show More