Compare commits
58 Commits
fix/issue_
...
sfe-packag
| Author | SHA1 | Date | |
|---|---|---|---|
| a9373d60d0 | |||
| 82fadf587b | |||
| 220378b3f2 | |||
| 363d655378 | |||
| e93b2a1a75 | |||
| 76665cf65e | |||
| 3ad7f4dc24 | |||
| c5045e8792 | |||
| a8c9b3a8ba | |||
| 148506639a | |||
| 53814d9919 | |||
| 08b04c32f5 | |||
| 1c1d97339d | |||
| cafa9c1737 | |||
| 5f64347ba1 | |||
| 45ef54480a | |||
| a3dc8af4c6 | |||
| 36933a0aca | |||
| 8f689890df | |||
| ec49b2e0e0 | |||
| 22ebe05706 | |||
| f0e58a6f49 | |||
| a3d642c08e | |||
| 5d42cb9185 | |||
| 1fd0cc5bb5 | |||
| deef365ff5 | |||
| d1ae6287f2 | |||
| 2e152cd264 | |||
| f5941e403b | |||
| ff3cf8c10e | |||
| bfa6328172 | |||
| 4c9691c932 | |||
| a0f1566b4c | |||
| 46261a4f42 | |||
| 8b42ff1e97 | |||
| ca4cb0d251 | |||
| a5a0fa79dd | |||
| c06a871f61 | |||
| 4a3df67134 | |||
| 422ccf61fa | |||
| d989f23907 | |||
| 059180edef | |||
| 22f30634a8 | |||
| 35ff418c42 | |||
| 7826e7a605 | |||
| 64f1b8207d | |||
| b2c13f0614 | |||
| 6965628020 | |||
| 608f63e9a2 | |||
| 22fa3a7fba | |||
| bcfd6fefa7 | |||
| eae18d0016 | |||
| 4a12a57c5f | |||
| 71294b7deb | |||
| 5af907db0c | |||
| 63a118a2ba | |||
| d9a3c34a44 | |||
| 23bdad7574 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2025.2.2
|
current_version = 2025.2.3
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||||
@ -17,6 +17,8 @@ optional_value = final
|
|||||||
|
|
||||||
[bumpversion:file:pyproject.toml]
|
[bumpversion:file:pyproject.toml]
|
||||||
|
|
||||||
|
[bumpversion:file:uv.lock]
|
||||||
|
|
||||||
[bumpversion:file:package.json]
|
[bumpversion:file:package.json]
|
||||||
|
|
||||||
[bumpversion:file:docker-compose.yml]
|
[bumpversion:file:docker-compose.yml]
|
||||||
|
|||||||
5
.github/workflows/api-ts-publish.yml
vendored
5
.github/workflows/api-ts-publish.yml
vendored
@ -36,11 +36,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$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
|
- uses: peter-evans/create-pull-request@v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
|
|||||||
@ -30,7 +30,6 @@ WORKDIR /work/web
|
|||||||
|
|
||||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
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/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=bind,target=/work/web/scripts,src=./web/scripts \
|
||||||
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||||
npm ci --include=dev
|
npm ci --include=dev
|
||||||
@ -43,7 +42,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 3: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -76,7 +75,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/authentik ./cmd/server
|
go build -o /go/authentik ./cmd/server
|
||||||
|
|
||||||
# Stage 4: MaxMind GeoIP
|
# Stage 4: MaxMind GeoIP
|
||||||
@ -94,9 +93,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||||
|
|
||||||
# Stage 5: Download uv
|
# Stage 5: Download uv
|
||||||
FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
|
FROM ghcr.io/astral-sh/uv:0.6.12 AS uv
|
||||||
# Stage 6: Base python image
|
# Stage 6: Base python image
|
||||||
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
|
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base
|
||||||
|
|
||||||
ENV VENV_PATH="/ak-root/.venv" \
|
ENV VENV_PATH="/ak-root/.venv" \
|
||||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2025.2.2"
|
__version__ = "2025.2.3"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,7 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
||||||
"""Cache key where application list for user is saved"""
|
"""Cache key where application list for user is saved"""
|
||||||
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
|
key = f"{CACHE_PREFIX}app_access/{user_pk}"
|
||||||
if page_number:
|
if page_number:
|
||||||
key += f"/{page_number}"
|
key += f"/{page_number}"
|
||||||
return key
|
return key
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django_filters.filters import BooleanFilter
|
from django_filters.filters import BooleanFilter
|
||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import ReadOnlyField, SerializerMethodField
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
|
|
||||||
from authentik.core.api.object_types import TypesMixin
|
from authentik.core.api.object_types import TypesMixin
|
||||||
@ -18,10 +18,10 @@ from authentik.core.models import Provider
|
|||||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Provider Serializer"""
|
"""Provider Serializer"""
|
||||||
|
|
||||||
assigned_application_slug = SerializerMethodField()
|
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||||
assigned_application_name = SerializerMethodField()
|
assigned_application_name = ReadOnlyField(source="application.name")
|
||||||
assigned_backchannel_application_slug = SerializerMethodField()
|
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
||||||
assigned_backchannel_application_name = SerializerMethodField()
|
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
||||||
|
|
||||||
component = SerializerMethodField()
|
component = SerializerMethodField()
|
||||||
|
|
||||||
@ -31,38 +31,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
|||||||
return ""
|
return ""
|
||||||
return obj.component
|
return obj.component
|
||||||
|
|
||||||
def get_assigned_application_slug(self, obj: Provider) -> str:
|
|
||||||
"""Get application slug, return empty string if no application exists"""
|
|
||||||
try:
|
|
||||||
return obj.application.slug
|
|
||||||
except Provider.application.RelatedObjectDoesNotExist:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_assigned_application_name(self, obj: Provider) -> str:
|
|
||||||
"""Get application name, return empty string if no application exists"""
|
|
||||||
try:
|
|
||||||
return obj.application.name
|
|
||||||
except Provider.application.RelatedObjectDoesNotExist:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_assigned_backchannel_application_slug(self, obj: Provider) -> str:
|
|
||||||
"""Get backchannel application slug.
|
|
||||||
|
|
||||||
Returns an empty string if no backchannel application exists.
|
|
||||||
"""
|
|
||||||
if not obj.backchannel_application:
|
|
||||||
return ""
|
|
||||||
return obj.backchannel_application.slug or ""
|
|
||||||
|
|
||||||
def get_assigned_backchannel_application_name(self, obj: Provider) -> str:
|
|
||||||
"""Get backchannel application name.
|
|
||||||
|
|
||||||
Returns an empty string if no backchannel application exists.
|
|
||||||
"""
|
|
||||||
if not obj.backchannel_application:
|
|
||||||
return ""
|
|
||||||
return obj.backchannel_application.name or ""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
|
|||||||
@ -179,10 +179,13 @@ class UserSourceConnectionSerializer(SourceSerializer):
|
|||||||
"user",
|
"user",
|
||||||
"source",
|
"source",
|
||||||
"source_obj",
|
"source_obj",
|
||||||
|
"identifier",
|
||||||
"created",
|
"created",
|
||||||
|
"last_updated",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"created": {"read_only": True},
|
"created": {"read_only": True},
|
||||||
|
"last_updated": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -199,7 +202,7 @@ class UserSourceConnectionViewSet(
|
|||||||
queryset = UserSourceConnection.objects.all()
|
queryset = UserSourceConnection.objects.all()
|
||||||
serializer_class = UserSourceConnectionSerializer
|
serializer_class = UserSourceConnectionSerializer
|
||||||
filterset_fields = ["user", "source__slug"]
|
filterset_fields = ["user", "source__slug"]
|
||||||
search_fields = ["source__slug"]
|
search_fields = ["user__username", "source__slug", "identifier"]
|
||||||
ordering = ["source__slug", "pk"]
|
ordering = ["source__slug", "pk"]
|
||||||
owner_field = "user"
|
owner_field = "user"
|
||||||
|
|
||||||
@ -218,9 +221,11 @@ class GroupSourceConnectionSerializer(SourceSerializer):
|
|||||||
"source_obj",
|
"source_obj",
|
||||||
"identifier",
|
"identifier",
|
||||||
"created",
|
"created",
|
||||||
|
"last_updated",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"created": {"read_only": True},
|
"created": {"read_only": True},
|
||||||
|
"last_updated": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -237,6 +242,5 @@ class GroupSourceConnectionViewSet(
|
|||||||
queryset = GroupSourceConnection.objects.all()
|
queryset = GroupSourceConnection.objects.all()
|
||||||
serializer_class = GroupSourceConnectionSerializer
|
serializer_class = GroupSourceConnectionSerializer
|
||||||
filterset_fields = ["group", "source__slug"]
|
filterset_fields = ["group", "source__slug"]
|
||||||
search_fields = ["source__slug"]
|
search_fields = ["group__name", "source__slug", "identifier"]
|
||||||
ordering = ["source__slug", "pk"]
|
ordering = ["source__slug", "pk"]
|
||||||
owner_field = "user"
|
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
"""User API Views"""
|
"""User API Views"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from importlib import import_module
|
||||||
from json import loads
|
from json import loads
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails
|
|||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
class UserGroupSerializer(ModelSerializer):
|
class UserGroupSerializer(ModelSerializer):
|
||||||
@ -373,7 +375,7 @@ class UsersFilter(FilterSet):
|
|||||||
method="filter_attributes",
|
method="filter_attributes",
|
||||||
)
|
)
|
||||||
|
|
||||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
|
||||||
uuid = UUIDFilter(field_name="uuid")
|
uuid = UUIDFilter(field_name="uuid")
|
||||||
|
|
||||||
path = CharFilter(field_name="path")
|
path = CharFilter(field_name="path")
|
||||||
@ -391,6 +393,11 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all().order_by("name"),
|
queryset=Group.objects.all().order_by("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def filter_is_superuser(self, queryset, name, value):
|
||||||
|
if value:
|
||||||
|
return queryset.filter(ak_groups__is_superuser=True).distinct()
|
||||||
|
return queryset.exclude(ak_groups__is_superuser=True).distinct()
|
||||||
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -769,7 +776,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
if not instance.is_active:
|
if not instance.is_active:
|
||||||
sessions = AuthenticatedSession.objects.filter(user=instance)
|
sessions = AuthenticatedSession.objects.filter(user=instance)
|
||||||
session_ids = sessions.values_list("session_key", flat=True)
|
session_ids = sessions.values_list("session_key", flat=True)
|
||||||
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
|
for session in session_ids:
|
||||||
|
SessionStore(session).delete()
|
||||||
sessions.delete()
|
sessions.delete()
|
||||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
# 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -761,11 +761,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
"""Return component used to edit this object"""
|
"""Return component used to edit this object"""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return ""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def property_mapping_type(self) -> "type[PropertyMapping]":
|
def property_mapping_type(self) -> "type[PropertyMapping]":
|
||||||
"""Return property mapping type used by this object"""
|
"""Return property mapping type used by this object"""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
from authentik.core.models import PropertyMapping
|
||||||
|
|
||||||
|
return PropertyMapping
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
|
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
|
||||||
@ -780,10 +786,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
|
|
||||||
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
||||||
"""Get base properties for a user to build final properties upon."""
|
"""Get base properties for a user to build final properties upon."""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return {}
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
||||||
"""Get base properties for a group to build final properties upon."""
|
"""Get base properties for a group to build final properties upon."""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return {}
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -814,6 +824,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
||||||
|
identifier = models.TextField()
|
||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
@ -827,6 +838,10 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("user", "source"),)
|
unique_together = (("user", "source"),)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("identifier",)),
|
||||||
|
models.Index(fields=("source", "identifier")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
"""authentik core signals"""
|
"""authentik core signals"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.signals import Signal
|
from django.core.signals import Signal
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@ -25,6 +28,7 @@ password_changed = Signal()
|
|||||||
login_failed = Signal()
|
login_failed = Signal()
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Application)
|
@receiver(post_save, sender=Application)
|
||||||
@ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
|||||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||||
"""Delete session when authenticated session is deleted"""
|
"""Delete session when authenticated session is deleted"""
|
||||||
cache_key = f"{KEY_PREFIX}{instance.session_key}"
|
SessionStore(instance.session_key).delete()
|
||||||
cache.delete(cache_key)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save)
|
@receiver(pre_save)
|
||||||
|
|||||||
@ -36,6 +36,7 @@ from authentik.flows.planner import (
|
|||||||
)
|
)
|
||||||
from authentik.flows.stage import StageView
|
from authentik.flows.stage import StageView
|
||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
|
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
|
||||||
|
from authentik.lib.utils.urls import is_url_absolute
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
from authentik.policies.denied import AccessDeniedResponse
|
||||||
from authentik.policies.utils import delete_none_values
|
from authentik.policies.utils import delete_none_values
|
||||||
@ -209,6 +210,8 @@ class SourceFlowManager:
|
|||||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||||
NEXT_ARG_NAME, "authentik_core:if-user"
|
NEXT_ARG_NAME, "authentik_core:if-user"
|
||||||
)
|
)
|
||||||
|
if not is_url_absolute(final_redirect):
|
||||||
|
final_redirect = "authentik_core:if-user"
|
||||||
flow_context.update(
|
flow_context.update(
|
||||||
{
|
{
|
||||||
# Since we authenticate the user by their token, they have no backend set
|
# Since we authenticate the user by their token, they have no backend set
|
||||||
|
|||||||
@ -133,8 +133,6 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"provider_obj": {
|
"provider_obj": {
|
||||||
"assigned_application_name": "allowed",
|
"assigned_application_name": "allowed",
|
||||||
"assigned_application_slug": "allowed",
|
"assigned_application_slug": "allowed",
|
||||||
"assigned_backchannel_application_name": "",
|
|
||||||
"assigned_backchannel_application_slug": "",
|
|
||||||
"authentication_flow": None,
|
"authentication_flow": None,
|
||||||
"invalidation_flow": None,
|
"invalidation_flow": None,
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||||
@ -188,8 +186,6 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"provider_obj": {
|
"provider_obj": {
|
||||||
"assigned_application_name": "allowed",
|
"assigned_application_name": "allowed",
|
||||||
"assigned_application_slug": "allowed",
|
"assigned_application_slug": "allowed",
|
||||||
"assigned_backchannel_application_name": "",
|
|
||||||
"assigned_backchannel_application_slug": "",
|
|
||||||
"authentication_flow": None,
|
"authentication_flow": None,
|
||||||
"invalidation_flow": None,
|
"invalidation_flow": None,
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||||
|
|||||||
@ -3,8 +3,7 @@
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.models import PropertyMapping
|
||||||
from authentik.core.models import Application, PropertyMapping, Provider
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
|
||||||
|
|
||||||
@ -25,51 +24,3 @@ class TestProvidersAPI(APITestCase):
|
|||||||
reverse("authentik_api:provider-types"),
|
reverse("authentik_api:provider-types"),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_provider_serializer_without_application(self):
|
|
||||||
"""Test that Provider serializer handles missing application gracefully"""
|
|
||||||
# Create a provider without an application
|
|
||||||
provider = Provider.objects.create(name="test-provider")
|
|
||||||
|
|
||||||
serializer = ProviderSerializer(instance=provider)
|
|
||||||
serialized_data = serializer.data
|
|
||||||
|
|
||||||
# Check that fields return empty strings when no application exists
|
|
||||||
self.assertEqual(serialized_data["assigned_application_slug"], "")
|
|
||||||
self.assertEqual(serialized_data["assigned_application_name"], "")
|
|
||||||
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
|
|
||||||
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
|
|
||||||
|
|
||||||
def test_provider_serializer_with_application(self):
|
|
||||||
"""Test that Provider serializer correctly includes application data"""
|
|
||||||
# Create an application
|
|
||||||
app = Application.objects.create(name="Test App", slug="test-app")
|
|
||||||
|
|
||||||
# Create a provider with an application
|
|
||||||
provider = Provider.objects.create(name="test-provider-with-app")
|
|
||||||
app.provider = provider
|
|
||||||
app.save()
|
|
||||||
|
|
||||||
serializer = ProviderSerializer(instance=provider)
|
|
||||||
serialized_data = serializer.data
|
|
||||||
|
|
||||||
# Check that fields return correct values when application exists
|
|
||||||
self.assertEqual(serialized_data["assigned_application_slug"], "test-app")
|
|
||||||
self.assertEqual(serialized_data["assigned_application_name"], "Test App")
|
|
||||||
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
|
|
||||||
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
|
|
||||||
|
|
||||||
def test_provider_api_response(self):
|
|
||||||
"""Test that the API response includes empty strings for missing applications"""
|
|
||||||
# Create a provider without an application
|
|
||||||
provider = Provider.objects.create(name="test-provider-api")
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("authentik_api:provider-detail", kwargs={"pk": provider.pk}),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(response.data["assigned_application_slug"], "")
|
|
||||||
self.assertEqual(response.data["assigned_application_name"], "")
|
|
||||||
self.assertEqual(response.data["assigned_backchannel_application_slug"], "")
|
|
||||||
self.assertEqual(response.data["assigned_backchannel_application_name"], "")
|
|
||||||
|
|||||||
19
authentik/core/tests/test_source_api.py
Normal file
19
authentik/core/tests/test_source_api.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceAPI(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_builtin_source_used_by(self):
|
||||||
|
"""Test Providers's types endpoint"""
|
||||||
|
apps.get_app_config("authentik_core").source_inbuilt()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from json import loads
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -15,7 +16,12 @@ from authentik.core.models import (
|
|||||||
User,
|
User,
|
||||||
UserTypes,
|
UserTypes,
|
||||||
)
|
)
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
from authentik.core.tests.utils import (
|
||||||
|
create_test_admin_user,
|
||||||
|
create_test_brand,
|
||||||
|
create_test_flow,
|
||||||
|
create_test_user,
|
||||||
|
)
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
@ -26,7 +32,7 @@ class TestUsersAPI(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.admin = create_test_admin_user()
|
self.admin = create_test_admin_user()
|
||||||
self.user = User.objects.create(username="test-user")
|
self.user = create_test_user()
|
||||||
|
|
||||||
def test_filter_type(self):
|
def test_filter_type(self):
|
||||||
"""Test API filtering by type"""
|
"""Test API filtering by type"""
|
||||||
@ -41,6 +47,35 @@ class TestUsersAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_filter_is_superuser(self):
|
||||||
|
"""Test API filtering by superuser status"""
|
||||||
|
User.objects.all().delete()
|
||||||
|
admin = create_test_admin_user()
|
||||||
|
self.client.force_login(admin)
|
||||||
|
# Test superuser
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1)
|
||||||
|
self.assertEqual(body["results"][0]["username"], admin.username)
|
||||||
|
# Test non-superuser
|
||||||
|
user = create_test_user()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1, body)
|
||||||
|
self.assertEqual(body["results"][0]["username"], user.username)
|
||||||
|
|
||||||
def test_list_with_groups(self):
|
def test_list_with_groups(self):
|
||||||
"""Test listing with groups"""
|
"""Test listing with groups"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
@ -99,6 +134,8 @@ class TestUsersAPI(APITestCase):
|
|||||||
def test_recovery_email_no_flow(self):
|
def test_recovery_email_no_flow(self):
|
||||||
"""Test user recovery link (no recovery flow set)"""
|
"""Test user recovery link (no recovery flow set)"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
self.user.email = ""
|
||||||
|
self.user.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,7 +13,11 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
|
|||||||
from authentik.core.api.groups import GroupViewSet
|
from authentik.core.api.groups import GroupViewSet
|
||||||
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
||||||
from authentik.core.api.providers import ProviderViewSet
|
from authentik.core.api.providers import ProviderViewSet
|
||||||
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
from authentik.core.api.sources import (
|
||||||
|
GroupSourceConnectionViewSet,
|
||||||
|
SourceViewSet,
|
||||||
|
UserSourceConnectionViewSet,
|
||||||
|
)
|
||||||
from authentik.core.api.tokens import TokenViewSet
|
from authentik.core.api.tokens import TokenViewSet
|
||||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
@ -81,6 +85,7 @@ api_urlpatterns = [
|
|||||||
("core/tokens", TokenViewSet),
|
("core/tokens", TokenViewSet),
|
||||||
("sources/all", SourceViewSet),
|
("sources/all", SourceViewSet),
|
||||||
("sources/user_connections/all", UserSourceConnectionViewSet),
|
("sources/user_connections/all", UserSourceConnectionViewSet),
|
||||||
|
("sources/group_connections/all", GroupSourceConnectionViewSet),
|
||||||
("providers/all", ProviderViewSet),
|
("providers/all", ProviderViewSet),
|
||||||
("propertymappings/all", PropertyMappingViewSet),
|
("propertymappings/all", PropertyMappingViewSet),
|
||||||
("authenticators/all", DeviceViewSet, "device"),
|
("authenticators/all", DeviceViewSet, "device"),
|
||||||
|
|||||||
@ -49,6 +49,6 @@
|
|||||||
</main>
|
</main>
|
||||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
<script src="{% static 'dist/sfe/main.js' %}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -69,6 +69,7 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
|||||||
SESSION_KEY_GET = "authentik/flows/get"
|
SESSION_KEY_GET = "authentik/flows/get"
|
||||||
SESSION_KEY_POST = "authentik/flows/post"
|
SESSION_KEY_POST = "authentik/flows/post"
|
||||||
SESSION_KEY_HISTORY = "authentik/flows/history"
|
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||||
|
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
|
||||||
QS_KEY_TOKEN = "flow_token" # nosec
|
QS_KEY_TOKEN = "flow_token" # nosec
|
||||||
QS_QUERY = "query"
|
QS_QUERY = "query"
|
||||||
|
|
||||||
@ -453,6 +454,7 @@ class FlowExecutorView(APIView):
|
|||||||
SESSION_KEY_APPLICATION_PRE,
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
SESSION_KEY_PLAN,
|
SESSION_KEY_PLAN,
|
||||||
SESSION_KEY_GET,
|
SESSION_KEY_GET,
|
||||||
|
SESSION_KEY_AUTH_STARTED,
|
||||||
# We might need the initial POST payloads for later requests
|
# We might need the initial POST payloads for later requests
|
||||||
# SESSION_KEY_POST,
|
# SESSION_KEY_POST,
|
||||||
# We don't delete the history on purpose, as a user might
|
# We don't delete the history on purpose, as a user might
|
||||||
|
|||||||
@ -6,14 +6,22 @@ from django.shortcuts import get_object_or_404
|
|||||||
from ua_parser.user_agent_parser import Parse
|
from ua_parser.user_agent_parser import Parse
|
||||||
|
|
||||||
from authentik.core.views.interface import InterfaceView
|
from authentik.core.views.interface import InterfaceView
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
|
||||||
|
|
||||||
|
|
||||||
class FlowInterfaceView(InterfaceView):
|
class FlowInterfaceView(InterfaceView):
|
||||||
"""Flow interface"""
|
"""Flow interface"""
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||||
|
kwargs["flow"] = flow
|
||||||
|
if (
|
||||||
|
not self.request.user.is_authenticated
|
||||||
|
and flow.designation == FlowDesignation.AUTHENTICATION
|
||||||
|
):
|
||||||
|
self.request.session[SESSION_KEY_AUTH_STARTED] = True
|
||||||
|
self.request.session.save()
|
||||||
kwargs["inspector"] = "inspector" in self.request.GET
|
kwargs["inspector"] = "inspector" in self.request.GET
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,15 @@ class SerializerModel(models.Model):
|
|||||||
@property
|
@property
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
def serializer(self) -> type[BaseSerializer]:
|
||||||
"""Get serializer for this model"""
|
"""Get serializer for this model"""
|
||||||
|
# Special handling for built-in source
|
||||||
|
if (
|
||||||
|
hasattr(self, "managed")
|
||||||
|
and hasattr(self, "MANAGED_INBUILT")
|
||||||
|
and self.managed == self.MANAGED_INBUILT
|
||||||
|
):
|
||||||
|
from authentik.core.api.sources import SourceSerializer
|
||||||
|
|
||||||
|
return SourceSerializer
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,3 +35,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
|
|||||||
label = "authentik_policies"
|
label = "authentik_policies"
|
||||||
verbose_name = "authentik Policies"
|
verbose_name = "authentik Policies"
|
||||||
default = True
|
default = True
|
||||||
|
mountpoint = "policy/"
|
||||||
|
|||||||
89
authentik/policies/templates/policies/buffer.html
Normal file
89
authentik/policies/templates/policies/buffer.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{% extends 'login/base_full.html' %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
let redirecting = false;
|
||||||
|
const checkAuth = async () => {
|
||||||
|
if (redirecting) return true;
|
||||||
|
const url = "{{ check_auth_url }}";
|
||||||
|
console.debug("authentik/policies/buffer: Checking authentication...");
|
||||||
|
try {
|
||||||
|
const result = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
});
|
||||||
|
if (result.status >= 400) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
console.debug("authentik/policies/buffer: Continuing");
|
||||||
|
redirecting = true;
|
||||||
|
if ("{{ auth_req_method }}" === "post") {
|
||||||
|
document.querySelector("form").submit();
|
||||||
|
} else {
|
||||||
|
window.location.assign("{{ continue_url|escapejs }}");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let timeout = 100;
|
||||||
|
let offset = 20;
|
||||||
|
let attempt = 0;
|
||||||
|
const main = async () => {
|
||||||
|
attempt += 1;
|
||||||
|
await checkAuth();
|
||||||
|
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
|
||||||
|
setTimeout(main, timeout);
|
||||||
|
timeout += (offset * attempt);
|
||||||
|
if (timeout >= 2000) {
|
||||||
|
timeout = 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", async () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
|
||||||
|
await checkAuth();
|
||||||
|
});
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_title %}
|
||||||
|
{% trans 'Waiting for authentication...' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card %}
|
||||||
|
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
|
||||||
|
{% if auth_req_method == "post" %}
|
||||||
|
{% for key, value in auth_req_body.items %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pf-c-empty-state">
|
||||||
|
<div class="pf-c-empty-state__content">
|
||||||
|
<div class="pf-c-empty-state__icon">
|
||||||
|
<span class="pf-c-spinner pf-m-xl" role="progressbar">
|
||||||
|
<span class="pf-c-spinner__clipper"></span>
|
||||||
|
<span class="pf-c-spinner__lead-ball"></span>
|
||||||
|
<span class="pf-c-spinner__tail-ball"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="pf-c-title pf-m-lg">
|
||||||
|
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-c-form__group pf-m-action">
|
||||||
|
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
|
||||||
|
{% trans "Authenticate in this tab" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
121
authentik/policies/tests/test_views.py
Normal file
121
authentik/policies/tests/test_views.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik.core.models import Application, Provider
|
||||||
|
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||||
|
from authentik.flows.models import FlowDesignation
|
||||||
|
from authentik.flows.planner import FlowPlan
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.lib.tests.utils import dummy_get_response
|
||||||
|
from authentik.policies.views import (
|
||||||
|
QS_BUFFER_ID,
|
||||||
|
SESSION_KEY_BUFFER,
|
||||||
|
BufferedPolicyAccessView,
|
||||||
|
BufferView,
|
||||||
|
PolicyAccessView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolicyViews(TestCase):
|
||||||
|
"""Test PolicyAccessView"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.user = create_test_user()
|
||||||
|
|
||||||
|
def test_pav(self):
|
||||||
|
"""Test simple policy access view"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
|
||||||
|
class TestView(PolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = self.user
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.content, b"foo")
|
||||||
|
|
||||||
|
def test_pav_buffer(self):
|
||||||
|
"""Test simple policy access view"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
|
||||||
|
class TestView(BufferedPolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||||
|
req.session.save()
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 302)
|
||||||
|
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
|
||||||
|
|
||||||
|
def test_pav_buffer_skip(self):
|
||||||
|
"""Test simple policy access view (skip buffer)"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
|
||||||
|
class TestView(BufferedPolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/?skip_buffer=true")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||||
|
req.session.save()
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 302)
|
||||||
|
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
|
||||||
|
|
||||||
|
def test_buffer(self):
|
||||||
|
"""Test buffer view"""
|
||||||
|
uid = generate_id()
|
||||||
|
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
ts = generate_id()
|
||||||
|
req.session[SESSION_KEY_BUFFER % uid] = {
|
||||||
|
"method": "get",
|
||||||
|
"body": {},
|
||||||
|
"url": f"/{ts}",
|
||||||
|
}
|
||||||
|
req.session.save()
|
||||||
|
|
||||||
|
res = BufferView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn(ts, res.render().content.decode())
|
||||||
@ -1,7 +1,14 @@
|
|||||||
"""API URLs"""
|
"""API URLs"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from authentik.policies.api.bindings import PolicyBindingViewSet
|
from authentik.policies.api.bindings import PolicyBindingViewSet
|
||||||
from authentik.policies.api.policies import PolicyViewSet
|
from authentik.policies.api.policies import PolicyViewSet
|
||||||
|
from authentik.policies.views import BufferView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("buffer", BufferView.as_view(), name="buffer"),
|
||||||
|
]
|
||||||
|
|
||||||
api_urlpatterns = [
|
api_urlpatterns = [
|
||||||
("policies/all", PolicyViewSet),
|
("policies/all", PolicyViewSet),
|
||||||
|
|||||||
@ -1,23 +1,37 @@
|
|||||||
"""authentik access helper classes"""
|
"""authentik access helper classes"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import AccessMixin
|
from django.contrib.auth.mixins import AccessMixin
|
||||||
from django.contrib.auth.views import redirect_to_login
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse, QueryDict
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import TemplateView, View
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import Application, Provider, User
|
from authentik.core.models import Application, Provider, User
|
||||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.planner import FlowPlan
|
||||||
|
from authentik.flows.views.executor import (
|
||||||
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
|
SESSION_KEY_AUTH_STARTED,
|
||||||
|
SESSION_KEY_PLAN,
|
||||||
|
SESSION_KEY_POST,
|
||||||
|
)
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
from authentik.policies.denied import AccessDeniedResponse
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
QS_BUFFER_ID = "af_bf_id"
|
||||||
|
QS_SKIP_BUFFER = "skip_buffer"
|
||||||
|
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
|
||||||
|
|
||||||
|
|
||||||
class RequestValidationError(SentryIgnoredException):
|
class RequestValidationError(SentryIgnoredException):
|
||||||
@ -125,3 +139,65 @@ class PolicyAccessView(AccessMixin, View):
|
|||||||
for message in result.messages:
|
for message in result.messages:
|
||||||
messages.error(self.request, _(message))
|
messages.error(self.request, _(message))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def url_with_qs(url: str, **kwargs):
|
||||||
|
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
|
||||||
|
parameters are retained"""
|
||||||
|
if "?" not in url:
|
||||||
|
return url + f"?{urlencode(kwargs)}"
|
||||||
|
url, _, qs = url.partition("?")
|
||||||
|
qs = QueryDict(qs, mutable=True)
|
||||||
|
qs.update(kwargs)
|
||||||
|
return url + f"?{urlencode(qs.items())}"
|
||||||
|
|
||||||
|
|
||||||
|
class BufferView(TemplateView):
|
||||||
|
"""Buffer view"""
|
||||||
|
|
||||||
|
template_name = "policies/buffer.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
buf_id = self.request.GET.get(QS_BUFFER_ID)
|
||||||
|
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
|
||||||
|
kwargs["auth_req_method"] = buffer["method"]
|
||||||
|
kwargs["auth_req_body"] = buffer["body"]
|
||||||
|
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
|
||||||
|
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
|
||||||
|
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BufferedPolicyAccessView(PolicyAccessView):
|
||||||
|
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
|
||||||
|
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
|
||||||
|
if plan:
|
||||||
|
flow = Flow.objects.filter(pk=plan.flow_pk).first()
|
||||||
|
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
|
||||||
|
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
if not plan and authenticating is None:
|
||||||
|
LOGGER.debug("Not buffering request, no flow plan active")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
if self.request.GET.get(QS_SKIP_BUFFER):
|
||||||
|
LOGGER.debug("Not buffering request, explicit skip")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
buffer_id = str(uuid4())
|
||||||
|
LOGGER.debug("Buffering access request", bf_id=buffer_id)
|
||||||
|
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
|
||||||
|
"body": self.request.POST,
|
||||||
|
"url": self.request.build_absolute_uri(self.request.get_full_path()),
|
||||||
|
"method": self.request.method.lower(),
|
||||||
|
}
|
||||||
|
return redirect(
|
||||||
|
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
|
if QS_BUFFER_ID in self.request.GET:
|
||||||
|
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
|
||||||
|
return response
|
||||||
|
|||||||
@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
|
|||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
PKCE_METHOD_PLAIN,
|
PKCE_METHOD_PLAIN,
|
||||||
PKCE_METHOD_S256,
|
PKCE_METHOD_S256,
|
||||||
@ -328,7 +328,7 @@ class OAuthAuthorizationParams:
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationFlowInitView(PolicyAccessView):
|
class AuthorizationFlowInitView(BufferedPolicyAccessView):
|
||||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
params: OAuthAuthorizationParams
|
params: OAuthAuthorizationParams
|
||||||
|
|||||||
@ -74,8 +74,6 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
"assigned_backchannel_application_slug": "",
|
|
||||||
"assigned_backchannel_application_name": "",
|
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
"verbose_name_plural": "RAC Providers",
|
"verbose_name_plural": "RAC Providers",
|
||||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
"meta_model_name": "authentik_providers_rac.racprovider",
|
||||||
@ -126,8 +124,6 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
"assigned_backchannel_application_slug": "",
|
|
||||||
"assigned_backchannel_application_name": "",
|
|
||||||
"connection_expiry": "hours=8",
|
"connection_expiry": "hours=8",
|
||||||
"delete_token_on_disconnect": False,
|
"delete_token_on_disconnect": False,
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
@ -157,8 +153,6 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
"assigned_backchannel_application_slug": "",
|
|
||||||
"assigned_backchannel_application_name": "",
|
|
||||||
"connection_expiry": "hours=8",
|
"connection_expiry": "hours=8",
|
||||||
"delete_token_on_disconnect": False,
|
"delete_token_on_disconnect": False,
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
|
|||||||
@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
|||||||
from authentik.flows.stage import RedirectStage
|
from authentik.flows.stage import RedirectStage
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.policies.views import PolicyAccessView
|
from authentik.policies.views import BufferedPolicyAccessView
|
||||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||||
|
|
||||||
|
|
||||||
class RACStartView(PolicyAccessView):
|
class RACStartView(BufferedPolicyAccessView):
|
||||||
"""Start a RAC connection by checking access and creating a connection token"""
|
"""Start a RAC connection by checking access and creating a connection token"""
|
||||||
|
|
||||||
endpoint: Endpoint
|
endpoint: Endpoint
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-03-31 13:50
|
||||||
|
|
||||||
|
import authentik.lib.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlprovider",
|
||||||
|
name="acs_url",
|
||||||
|
field=models.TextField(
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="ACS URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -10,6 +10,7 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.core.api.object_types import CreatableType
|
from authentik.core.api.object_types import CreatableType
|
||||||
from authentik.core.models import PropertyMapping, Provider
|
from authentik.core.models import PropertyMapping, Provider
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.lib.models import DomainlessURLValidator
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DSA_SHA1,
|
DSA_SHA1,
|
||||||
@ -40,7 +41,9 @@ class SAMLBindings(models.TextChoices):
|
|||||||
class SAMLProvider(Provider):
|
class SAMLProvider(Provider):
|
||||||
"""SAML 2.0 Endpoint for applications which support SAML."""
|
"""SAML 2.0 Endpoint for applications which support SAML."""
|
||||||
|
|
||||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
acs_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
|
||||||
|
)
|
||||||
audience = models.TextField(
|
audience = models.TextField(
|
||||||
default="",
|
default="",
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
|
|||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.views import PolicyAccessView
|
from authentik.policies.views import BufferedPolicyAccessView
|
||||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||||
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
|
|||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class SAMLSSOView(PolicyAccessView):
|
class SAMLSSOView(BufferedPolicyAccessView):
|
||||||
"""SAML SSO Base View, which plans a flow and injects our final stage.
|
"""SAML SSO Base View, which plans a flow and injects our final stage.
|
||||||
Calls get/post handler."""
|
Calls get/post handler."""
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ class SAMLSSOView(PolicyAccessView):
|
|||||||
|
|
||||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
"""GET and POST use the same handler, but we can't
|
"""GET and POST use the same handler, but we can't
|
||||||
override .dispatch easily because PolicyAccessView's dispatch"""
|
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
|
||||||
return self.get(request, application_slug)
|
return self.get(request, application_slug)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
"""Kerberos Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
GroupSourceConnectionSerializer,
|
GroupSourceConnectionSerializer,
|
||||||
GroupSourceConnectionViewSet,
|
GroupSourceConnectionViewSet,
|
||||||
UserSourceConnectionSerializer,
|
UserSourceConnectionSerializer,
|
||||||
|
UserSourceConnectionViewSet,
|
||||||
)
|
)
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.sources.kerberos.models import (
|
from authentik.sources.kerberos.models import (
|
||||||
GroupKerberosSourceConnection,
|
GroupKerberosSourceConnection,
|
||||||
UserKerberosSourceConnection,
|
UserKerberosSourceConnection,
|
||||||
@ -15,33 +13,20 @@ from authentik.sources.kerberos.models import (
|
|||||||
|
|
||||||
|
|
||||||
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""Kerberos Source Serializer"""
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = UserKerberosSourceConnection
|
model = UserKerberosSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
|
||||||
|
|
||||||
|
|
||||||
class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
|
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserKerberosSourceConnection.objects.all()
|
queryset = UserKerberosSourceConnection.objects.all()
|
||||||
serializer_class = UserKerberosSourceConnectionSerializer
|
serializer_class = UserKerberosSourceConnectionSerializer
|
||||||
filterset_fields = ["source__slug"]
|
|
||||||
search_fields = ["source__slug"]
|
|
||||||
ordering = ["source__slug"]
|
|
||||||
owner_field = "user"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupKerberosSourceConnection
|
model = GroupKerberosSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupKerberosSourceConnection.objects.all()
|
queryset = GroupKerberosSourceConnection.objects.all()
|
||||||
serializer_class = GroupKerberosSourceConnectionSerializer
|
serializer_class = GroupKerberosSourceConnectionSerializer
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -372,8 +372,6 @@ class KerberosSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserKerberosSourceConnection(UserSourceConnection):
|
class UserKerberosSourceConnection(UserSourceConnection):
|
||||||
"""Connection to configured Kerberos Sources."""
|
"""Connection to configured Kerberos Sources."""
|
||||||
|
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.kerberos.api.source_connection import (
|
from authentik.sources.kerberos.api.source_connection import (
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"""OAuth Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,11 +10,9 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
|
|||||||
|
|
||||||
|
|
||||||
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""OAuth Source Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserOAuthSourceConnection
|
model = UserOAuthSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
|
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||||
"access_token": {"write_only": True},
|
"access_token": {"write_only": True},
|
||||||
@ -24,21 +20,15 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserOAuthSourceConnection.objects.all()
|
queryset = UserOAuthSourceConnection.objects.all()
|
||||||
serializer_class = UserOAuthSourceConnectionSerializer
|
serializer_class = UserOAuthSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupOAuthSourceConnection
|
model = GroupOAuthSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupOAuthSourceConnection.objects.all()
|
queryset = GroupOAuthSourceConnection.objects.all()
|
||||||
serializer_class = GroupOAuthSourceConnectionSerializer
|
serializer_class = GroupOAuthSourceConnectionSerializer
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -286,7 +286,6 @@ class OAuthSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserOAuthSourceConnection(UserSourceConnection):
|
class UserOAuthSourceConnection(UserSourceConnection):
|
||||||
"""Authorized remote OAuth provider."""
|
"""Authorized remote OAuth provider."""
|
||||||
|
|
||||||
identifier = models.CharField(max_length=255)
|
|
||||||
access_token = models.TextField(blank=True, null=True, default=None)
|
access_token = models.TextField(blank=True, null=True, default=None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,14 +10,9 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
|
|||||||
|
|
||||||
|
|
||||||
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserPlexSourceConnection
|
model = UserPlexSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + [
|
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"]
|
||||||
"identifier",
|
|
||||||
"plex_token",
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||||
"plex_token": {"write_only": True},
|
"plex_token": {"write_only": True},
|
||||||
@ -27,21 +20,15 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
queryset = UserPlexSourceConnection.objects.all()
|
queryset = UserPlexSourceConnection.objects.all()
|
||||||
serializer_class = UserPlexSourceConnectionSerializer
|
serializer_class = UserPlexSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""Plex Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupPlexSourceConnection
|
model = GroupPlexSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupPlexSourceConnection.objects.all()
|
queryset = GroupPlexSourceConnection.objects.all()
|
||||||
serializer_class = GroupPlexSourceConnectionSerializer
|
serializer_class = GroupPlexSourceConnectionSerializer
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -141,7 +141,6 @@ class UserPlexSourceConnection(UserSourceConnection):
|
|||||||
"""Connect user and plex source"""
|
"""Connect user and plex source"""
|
||||||
|
|
||||||
plex_token = models.TextField()
|
plex_token = models.TextField()
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"""SAML Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,29 +10,20 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
|
|||||||
|
|
||||||
|
|
||||||
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""SAML Source Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserSAMLSourceConnection
|
model = UserSAMLSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
|
||||||
|
|
||||||
|
|
||||||
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserSAMLSourceConnection.objects.all()
|
queryset = UserSAMLSourceConnection.objects.all()
|
||||||
serializer_class = UserSAMLSourceConnectionSerializer
|
serializer_class = UserSAMLSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupSAMLSourceConnection
|
model = GroupSAMLSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupSAMLSourceConnection.objects.all()
|
queryset = GroupSAMLSourceConnection.objects.all()
|
||||||
serializer_class = GroupSAMLSourceConnectionSerializer
|
serializer_class = GroupSAMLSourceConnectionSerializer
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-03-31 13:53
|
||||||
|
|
||||||
|
import authentik.lib.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_sources_saml", "0017_fix_x509subjectname"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="slo_url",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Optional URL if your IDP supports Single-Logout.",
|
||||||
|
null=True,
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="SLO URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="sso_url",
|
||||||
|
field=models.TextField(
|
||||||
|
help_text="URL that the initial Login request is sent to.",
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="SSO URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -20,6 +20,7 @@ from authentik.crypto.models import CertificateKeyPair
|
|||||||
from authentik.flows.challenge import RedirectChallenge
|
from authentik.flows.challenge import RedirectChallenge
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||||
|
from authentik.lib.models import DomainlessURLValidator
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DSA_SHA1,
|
DSA_SHA1,
|
||||||
@ -91,11 +92,13 @@ class SAMLSource(Source):
|
|||||||
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
||||||
)
|
)
|
||||||
|
|
||||||
sso_url = models.URLField(
|
sso_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
verbose_name=_("SSO URL"),
|
verbose_name=_("SSO URL"),
|
||||||
help_text=_("URL that the initial Login request is sent to."),
|
help_text=_("URL that the initial Login request is sent to."),
|
||||||
)
|
)
|
||||||
slo_url = models.URLField(
|
slo_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
default=None,
|
default=None,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
@ -315,8 +318,6 @@ class SAMLSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserSAMLSourceConnection(UserSourceConnection):
|
class UserSAMLSourceConnection(UserSourceConnection):
|
||||||
"""Connection to configured SAML Sources."""
|
"""Connection to configured SAML Sources."""
|
||||||
|
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
|
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from authentik.flows.planner import (
|
|||||||
)
|
)
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||||
|
from authentik.lib.utils.urls import is_url_absolute
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.providers.saml.utils.encoding import nice64
|
from authentik.providers.saml.utils.encoding import nice64
|
||||||
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
|
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
|
||||||
@ -73,6 +74,8 @@ class InitiateView(View):
|
|||||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||||
NEXT_ARG_NAME, "authentik_core:if-user"
|
NEXT_ARG_NAME, "authentik_core:if-user"
|
||||||
)
|
)
|
||||||
|
if not is_url_absolute(final_redirect):
|
||||||
|
final_redirect = "authentik_core:if-user"
|
||||||
kwargs.update(
|
kwargs.update(
|
||||||
{
|
{
|
||||||
PLAN_CONTEXT_SSO: True,
|
PLAN_CONTEXT_SSO: True,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -104,6 +104,13 @@ def send_mail(
|
|||||||
# can't be converted to json)
|
# can't be converted to json)
|
||||||
message_object.attach(logo_data())
|
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)
|
LOGGER.debug("Sending mail", to=message_object.to)
|
||||||
backend.send_messages([message_object])
|
backend.send_messages([message_object])
|
||||||
Event.new(
|
Event.new(
|
||||||
|
|||||||
@ -97,6 +97,37 @@ class TestEmailStageSending(FlowTestCase):
|
|||||||
self.assertEqual(mail.outbox[0].subject, "authentik")
|
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||||
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
|
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):
|
def test_pending_fake_user(self):
|
||||||
"""Test with pending (fake) user"""
|
"""Test with pending (fake) user"""
|
||||||
self.flow.designation = FlowDesignation.RECOVERY
|
self.flow.designation = FlowDesignation.RECOVERY
|
||||||
|
|||||||
@ -142,35 +142,38 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
|||||||
raise ValidationError("Failed to authenticate.")
|
raise ValidationError("Failed to authenticate.")
|
||||||
self.pre_user = pre_user
|
self.pre_user = pre_user
|
||||||
|
|
||||||
# Password check
|
|
||||||
if current_stage.password_stage:
|
|
||||||
password = attrs.get("password", None)
|
|
||||||
if not password:
|
|
||||||
self.stage.logger.warning("Password not set for ident+auth attempt")
|
|
||||||
try:
|
|
||||||
with start_span(
|
|
||||||
op="authentik.stages.identification.authenticate",
|
|
||||||
name="User authenticate call (combo stage)",
|
|
||||||
):
|
|
||||||
user = authenticate(
|
|
||||||
self.stage.request,
|
|
||||||
current_stage.password_stage.backends,
|
|
||||||
current_stage,
|
|
||||||
username=self.pre_user.username,
|
|
||||||
password=password,
|
|
||||||
)
|
|
||||||
if not user:
|
|
||||||
raise ValidationError("Failed to authenticate.")
|
|
||||||
self.pre_user = user
|
|
||||||
except PermissionDenied as exc:
|
|
||||||
raise ValidationError(str(exc)) from exc
|
|
||||||
|
|
||||||
# Captcha check
|
# Captcha check
|
||||||
if captcha_stage := current_stage.captcha_stage:
|
if captcha_stage := current_stage.captcha_stage:
|
||||||
captcha_token = attrs.get("captcha_token", None)
|
captcha_token = attrs.get("captcha_token", None)
|
||||||
if not captcha_token:
|
if not captcha_token:
|
||||||
self.stage.logger.warning("Token not set for captcha attempt")
|
self.stage.logger.warning("Token not set for captcha attempt")
|
||||||
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
||||||
|
|
||||||
|
# Password check
|
||||||
|
if not current_stage.password_stage:
|
||||||
|
# No password stage select, don't validate the password
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
password = attrs.get("password", None)
|
||||||
|
if not password:
|
||||||
|
self.stage.logger.warning("Password not set for ident+auth attempt")
|
||||||
|
try:
|
||||||
|
with start_span(
|
||||||
|
op="authentik.stages.identification.authenticate",
|
||||||
|
name="User authenticate call (combo stage)",
|
||||||
|
):
|
||||||
|
user = authenticate(
|
||||||
|
self.stage.request,
|
||||||
|
current_stage.password_stage.backends,
|
||||||
|
current_stage,
|
||||||
|
username=self.pre_user.username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise ValidationError("Failed to authenticate.")
|
||||||
|
self.pre_user = user
|
||||||
|
except PermissionDenied as exc:
|
||||||
|
raise ValidationError(str(exc)) from exc
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema",
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "authentik 2025.2.2 Blueprint schema",
|
"title": "authentik 2025.2.3 Blueprint schema",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
"entries"
|
"entries"
|
||||||
@ -6423,8 +6423,6 @@
|
|||||||
},
|
},
|
||||||
"acs_url": {
|
"acs_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "ACS URL"
|
"title": "ACS URL"
|
||||||
},
|
},
|
||||||
@ -8233,7 +8231,6 @@
|
|||||||
},
|
},
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 255,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Identifier"
|
"title": "Identifier"
|
||||||
},
|
},
|
||||||
@ -8733,8 +8730,6 @@
|
|||||||
},
|
},
|
||||||
"sso_url": {
|
"sso_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "SSO URL",
|
"title": "SSO URL",
|
||||||
"description": "URL that the initial Login request is sent to."
|
"description": "URL that the initial Login request is sent to."
|
||||||
@ -8744,8 +8739,6 @@
|
|||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
],
|
],
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"title": "SLO URL",
|
"title": "SLO URL",
|
||||||
"description": "Optional URL if your IDP supports Single-Logout."
|
"description": "Optional URL if your IDP supports Single-Logout."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis:/data
|
- redis:/data
|
||||||
server:
|
server:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
@ -54,7 +54,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
worker:
|
worker:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: worker
|
command: worker
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
7
go.mod
7
go.mod
@ -1,9 +1,6 @@
|
|||||||
module goauthentik.io
|
module goauthentik.io
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
beryju.io/ldap v0.1.0
|
beryju.io/ldap v0.1.0
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0
|
github.com/coreos/go-oidc/v3 v3.13.0
|
||||||
@ -29,7 +26,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/wwt/guac v1.3.2
|
github.com/wwt/guac v1.3.2
|
||||||
goauthentik.io/api/v3 v3.2025022.6
|
goauthentik.io/api/v3 v3.2025023.2
|
||||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.28.0
|
||||||
golang.org/x/sync v0.12.0
|
golang.org/x/sync v0.12.0
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
|||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg=
|
goauthentik.io/api/v3 v3.2025023.2 h1:4XHlnykN5jQH78liQ4cp2Jf8eigvQImIJp+A+bsq1nA=
|
||||||
goauthentik.io/api/v3 v3.2025022.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
goauthentik.io/api/v3 v3.2025023.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
|||||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
|||||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION = "2025.2.2"
|
const VERSION = "2025.2.3"
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
//go:build requirefips
|
|
||||||
|
|
||||||
package backend
|
|
||||||
|
|
||||||
var FipsEnabled = true
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
//go:build !requirefips
|
|
||||||
|
|
||||||
package backend
|
|
||||||
|
|
||||||
var FipsEnabled = false
|
|
||||||
@ -2,6 +2,7 @@ package ak
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/fips140"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -203,7 +204,7 @@ func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
|
|||||||
"golangVersion": runtime.Version(),
|
"golangVersion": runtime.Version(),
|
||||||
"opensslEnabled": cryptobackend.OpensslEnabled,
|
"opensslEnabled": cryptobackend.OpensslEnabled,
|
||||||
"opensslVersion": cryptobackend.OpensslVersion(),
|
"opensslVersion": cryptobackend.OpensslVersion(),
|
||||||
"fipsEnabled": cryptobackend.FipsEnabled,
|
"fipsEnabled": fips140.Enabled(),
|
||||||
}
|
}
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -27,7 +27,7 @@ COPY . .
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/ldap ./cmd/ldap
|
go build -o /go/ldap ./cmd/ldap
|
||||||
|
|
||||||
# Stage 2: Run
|
# Stage 2: Run
|
||||||
|
|||||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@ -9,7 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"aws-cdk": "^2.1006.0",
|
"aws-cdk": "^2.1007.0",
|
||||||
"cross-env": "^7.0.3"
|
"cross-env": "^7.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -17,9 +17,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk": {
|
"node_modules/aws-cdk": {
|
||||||
"version": "2.1006.0",
|
"version": "2.1007.0",
|
||||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1006.0.tgz",
|
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz",
|
||||||
"integrity": "sha512-6qYnCt4mBN+3i/5F+FC2yMETkDHY/IL7gt3EuqKVPcaAO4jU7oXfVSlR60CYRkZWL4fnAurUV14RkJuJyVG/IA==",
|
"integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"aws-cdk": "^2.1006.0",
|
"aws-cdk": "^2.1007.0",
|
||||||
"cross-env": "^7.0.3"
|
"cross-env": "^7.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ Parameters:
|
|||||||
Description: authentik Docker image
|
Description: authentik Docker image
|
||||||
AuthentikVersion:
|
AuthentikVersion:
|
||||||
Type: String
|
Type: String
|
||||||
Default: 2025.2.2
|
Default: 2025.2.3
|
||||||
Description: authentik Docker image tag
|
Description: authentik Docker image tag
|
||||||
AuthentikServerCPU:
|
AuthentikServerCPU:
|
||||||
Type: Number
|
Type: Number
|
||||||
|
|||||||
@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
|
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@ -1220,6 +1220,20 @@ msgstr ""
|
|||||||
msgid "Reputation Scores"
|
msgid "Reputation Scores"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Waiting for authentication..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid ""
|
||||||
|
"You're already authenticating in another tab. This page will refresh once "
|
||||||
|
"authentication is completed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Authenticate in this tab"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: authentik/policies/templates/policies/denied.html
|
#: authentik/policies/templates/policies/denied.html
|
||||||
msgid "Permission denied"
|
msgid "Permission denied"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
# Manuel Viens, 2023
|
# Manuel Viens, 2023
|
||||||
# Mordecai, 2023
|
# Mordecai, 2023
|
||||||
# nerdinator <florian.dupret@gmail.com>, 2024
|
# nerdinator <florian.dupret@gmail.com>, 2024
|
||||||
# Tina, 2024
|
|
||||||
# Charles Leclerc, 2025
|
# Charles Leclerc, 2025
|
||||||
|
# Tina, 2025
|
||||||
# Marc Schmitt, 2025
|
# Marc Schmitt, 2025
|
||||||
#
|
#
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@ -19,7 +19,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
|
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
|
||||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||||
"Last-Translator: Marc Schmitt, 2025\n"
|
"Last-Translator: Marc Schmitt, 2025\n"
|
||||||
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
|
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
|
||||||
@ -1347,6 +1347,22 @@ msgstr "Score de Réputation"
|
|||||||
msgid "Reputation Scores"
|
msgid "Reputation Scores"
|
||||||
msgstr "Scores de Réputation"
|
msgstr "Scores de Réputation"
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Waiting for authentication..."
|
||||||
|
msgstr "En attente de l'authentification..."
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid ""
|
||||||
|
"You're already authenticating in another tab. This page will refresh once "
|
||||||
|
"authentication is completed."
|
||||||
|
msgstr ""
|
||||||
|
"Vous êtes déjà en cours d'authentification dans un autre onglet. Cette page "
|
||||||
|
"se rafraîchira lorsque l'authentification sera terminée."
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Authenticate in this tab"
|
||||||
|
msgstr "S'authentifier dans cet onglet"
|
||||||
|
|
||||||
#: authentik/policies/templates/policies/denied.html
|
#: authentik/policies/templates/policies/denied.html
|
||||||
msgid "Permission denied"
|
msgid "Permission denied"
|
||||||
msgstr "Permission refusée"
|
msgstr "Permission refusée"
|
||||||
|
|||||||
Binary file not shown.
@ -14,7 +14,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
|
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
|
||||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||||
"Last-Translator: deluxghost, 2025\n"
|
"Last-Translator: deluxghost, 2025\n"
|
||||||
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
|
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
|
||||||
@ -1234,6 +1234,20 @@ msgstr "信誉分数"
|
|||||||
msgid "Reputation Scores"
|
msgid "Reputation Scores"
|
||||||
msgstr "信誉分数"
|
msgstr "信誉分数"
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Waiting for authentication..."
|
||||||
|
msgstr "正在等待身份验证…"
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid ""
|
||||||
|
"You're already authenticating in another tab. This page will refresh once "
|
||||||
|
"authentication is completed."
|
||||||
|
msgstr "您正在另一个标签页中验证身份。身份验证完成后,此页面会刷新。"
|
||||||
|
|
||||||
|
#: authentik/policies/templates/policies/buffer.html
|
||||||
|
msgid "Authenticate in this tab"
|
||||||
|
msgstr "在此标签页中验证身份"
|
||||||
|
|
||||||
#: authentik/policies/templates/policies/denied.html
|
#: authentik/policies/templates/policies/denied.html
|
||||||
msgid "Permission denied"
|
msgid "Permission denied"
|
||||||
msgstr "权限被拒绝"
|
msgstr "权限被拒绝"
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2025.2.1",
|
"version": "2025.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2025.2.1"
|
"version": "2025.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2025.2.2",
|
"version": "2025.2.3",
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ COPY web .
|
|||||||
RUN npm run build-proxy
|
RUN npm run build-proxy
|
||||||
|
|
||||||
# Stage 2: Build
|
# Stage 2: Build
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -43,7 +43,7 @@ COPY . .
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/proxy ./cmd/proxy
|
go build -o /go/proxy ./cmd/proxy
|
||||||
|
|
||||||
# Stage 3: Run
|
# Stage 3: Run
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "authentik"
|
name = "authentik"
|
||||||
version = "2025.2.2"
|
version = "2025.2.3"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||||
requires-python = "==3.12.*"
|
requires-python = "==3.12.*"
|
||||||
@ -52,7 +52,7 @@ dependencies = [
|
|||||||
"pydantic-scim",
|
"pydantic-scim",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
"pyrad",
|
"pyrad",
|
||||||
"python-kadmin-rs ==0.5.3",
|
"python-kadmin-rs ==0.6.0",
|
||||||
"pyyaml",
|
"pyyaml",
|
||||||
"requests-oauthlib",
|
"requests-oauthlib",
|
||||||
"scim2-filter-parser",
|
"scim2-filter-parser",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -27,7 +27,7 @@ COPY . .
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/rac ./cmd/rac
|
go build -o /go/rac ./cmd/rac
|
||||||
|
|
||||||
# Stage 2: Run
|
# Stage 2: Run
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -27,7 +27,7 @@ COPY . .
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/radius ./cmd/radius
|
go build -o /go/radius ./cmd/radius
|
||||||
|
|
||||||
# Stage 2: Run
|
# Stage 2: Run
|
||||||
|
|||||||
714
schema.yml
714
schema.yml
File diff suppressed because it is too large
Load Diff
@ -410,3 +410,77 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
|||||||
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
|
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
|
||||||
"Permission denied",
|
"Permission denied",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"default/flow-default-authentication-flow.yaml",
|
||||||
|
"default/flow-default-invalidation-flow.yaml",
|
||||||
|
)
|
||||||
|
@apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
|
||||||
|
@apply_blueprint("system/providers-oauth2.yaml")
|
||||||
|
@reconcile_app("authentik_crypto")
|
||||||
|
def test_authorization_consent_implied_parallel(self):
|
||||||
|
"""test OpenID Provider flow (default authorization flow with implied consent)"""
|
||||||
|
# Bootstrap all needed objects
|
||||||
|
authorization_flow = Flow.objects.get(
|
||||||
|
slug="default-provider-authorization-implicit-consent"
|
||||||
|
)
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
client_type=ClientTypes.CONFIDENTIAL,
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret,
|
||||||
|
signing_key=create_test_cert(),
|
||||||
|
redirect_uris=[
|
||||||
|
RedirectURI(
|
||||||
|
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
authorization_flow=authorization_flow,
|
||||||
|
)
|
||||||
|
provider.property_mappings.set(
|
||||||
|
ScopeMapping.objects.filter(
|
||||||
|
scope_name__in=[
|
||||||
|
SCOPE_OPENID,
|
||||||
|
SCOPE_OPENID_EMAIL,
|
||||||
|
SCOPE_OPENID_PROFILE,
|
||||||
|
SCOPE_OFFLINE_ACCESS,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Application.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
slug=self.app_slug,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.driver.get(self.live_server_url)
|
||||||
|
login_window = self.driver.current_window_handle
|
||||||
|
|
||||||
|
self.driver.switch_to.new_window("tab")
|
||||||
|
grafana_window = self.driver.current_window_handle
|
||||||
|
self.driver.get("http://localhost:3000")
|
||||||
|
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
|
||||||
|
|
||||||
|
self.driver.switch_to.window(login_window)
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
self.driver.switch_to.window(grafana_window)
|
||||||
|
self.wait_for_url("http://localhost:3000/?orgId=1")
|
||||||
|
self.driver.get("http://localhost:3000/profile")
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
|
||||||
|
self.user.name,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"),
|
||||||
|
self.user.name,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"),
|
||||||
|
self.user.email,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"),
|
||||||
|
self.user.email,
|
||||||
|
)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry
|
|||||||
class TestProviderSAML(SeleniumTestCase):
|
class TestProviderSAML(SeleniumTestCase):
|
||||||
"""test SAML Provider flow"""
|
"""test SAML Provider flow"""
|
||||||
|
|
||||||
def setup_client(self, provider: SAMLProvider, force_post: bool = False):
|
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs):
|
||||||
"""Setup client saml-sp container which we test SAML against"""
|
"""Setup client saml-sp container which we test SAML against"""
|
||||||
metadata_url = (
|
metadata_url = (
|
||||||
self.url(
|
self.url(
|
||||||
@ -40,6 +40,7 @@ class TestProviderSAML(SeleniumTestCase):
|
|||||||
"SP_ENTITY_ID": provider.issuer,
|
"SP_ENTITY_ID": provider.issuer,
|
||||||
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
||||||
"SP_METADATA_URL": metadata_url,
|
"SP_METADATA_URL": metadata_url,
|
||||||
|
**kwargs,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -111,6 +112,74 @@ class TestProviderSAML(SeleniumTestCase):
|
|||||||
[self.user.email],
|
[self.user.email],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"default/flow-default-authentication-flow.yaml",
|
||||||
|
"default/flow-default-invalidation-flow.yaml",
|
||||||
|
)
|
||||||
|
@apply_blueprint(
|
||||||
|
"default/flow-default-provider-authorization-implicit-consent.yaml",
|
||||||
|
)
|
||||||
|
@apply_blueprint(
|
||||||
|
"system/providers-saml.yaml",
|
||||||
|
)
|
||||||
|
@reconcile_app("authentik_crypto")
|
||||||
|
def test_sp_initiated_implicit_post(self):
|
||||||
|
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
|
||||||
|
# Bootstrap all needed objects
|
||||||
|
authorization_flow = Flow.objects.get(
|
||||||
|
slug="default-provider-authorization-implicit-consent"
|
||||||
|
)
|
||||||
|
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||||
|
name="saml-test",
|
||||||
|
acs_url="http://localhost:9009/saml/acs",
|
||||||
|
audience="authentik-e2e",
|
||||||
|
issuer="authentik-e2e",
|
||||||
|
sp_binding=SAMLBindings.POST,
|
||||||
|
authorization_flow=authorization_flow,
|
||||||
|
signing_kp=create_test_cert(),
|
||||||
|
)
|
||||||
|
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||||
|
provider.save()
|
||||||
|
Application.objects.create(
|
||||||
|
name="SAML",
|
||||||
|
slug="authentik-saml",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
self.setup_client(provider, True)
|
||||||
|
self.driver.get("http://localhost:9009")
|
||||||
|
self.login()
|
||||||
|
self.wait_for_url("http://localhost:9009/")
|
||||||
|
|
||||||
|
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
|
||||||
|
[self.user.name],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"][
|
||||||
|
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
|
||||||
|
],
|
||||||
|
[self.user.username],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
|
||||||
|
[self.user.username],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
|
||||||
|
[str(self.user.pk)],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
|
||||||
|
[self.user.email],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
|
||||||
|
[self.user.email],
|
||||||
|
)
|
||||||
|
|
||||||
@retry()
|
@retry()
|
||||||
@apply_blueprint(
|
@apply_blueprint(
|
||||||
"default/flow-default-authentication-flow.yaml",
|
"default/flow-default-authentication-flow.yaml",
|
||||||
@ -450,3 +519,81 @@ class TestProviderSAML(SeleniumTestCase):
|
|||||||
lambda driver: driver.current_url.startswith(should_url),
|
lambda driver: driver.current_url.startswith(should_url),
|
||||||
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
|
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@retry()
|
||||||
|
@apply_blueprint(
|
||||||
|
"default/flow-default-authentication-flow.yaml",
|
||||||
|
"default/flow-default-invalidation-flow.yaml",
|
||||||
|
)
|
||||||
|
@apply_blueprint(
|
||||||
|
"default/flow-default-provider-authorization-implicit-consent.yaml",
|
||||||
|
)
|
||||||
|
@apply_blueprint(
|
||||||
|
"system/providers-saml.yaml",
|
||||||
|
)
|
||||||
|
@reconcile_app("authentik_crypto")
|
||||||
|
def test_sp_initiated_implicit_post_buffer(self):
|
||||||
|
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
|
||||||
|
# Bootstrap all needed objects
|
||||||
|
authorization_flow = Flow.objects.get(
|
||||||
|
slug="default-provider-authorization-implicit-consent"
|
||||||
|
)
|
||||||
|
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||||
|
name="saml-test",
|
||||||
|
acs_url=f"http://{self.host}:9009/saml/acs",
|
||||||
|
audience="authentik-e2e",
|
||||||
|
issuer="authentik-e2e",
|
||||||
|
sp_binding=SAMLBindings.POST,
|
||||||
|
authorization_flow=authorization_flow,
|
||||||
|
signing_kp=create_test_cert(),
|
||||||
|
)
|
||||||
|
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||||
|
provider.save()
|
||||||
|
Application.objects.create(
|
||||||
|
name="SAML",
|
||||||
|
slug="authentik-saml",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009")
|
||||||
|
|
||||||
|
self.driver.get(self.live_server_url)
|
||||||
|
login_window = self.driver.current_window_handle
|
||||||
|
self.driver.switch_to.new_window("tab")
|
||||||
|
client_window = self.driver.current_window_handle
|
||||||
|
# We need to access the SP on the same host as the IdP for SameSite cookies
|
||||||
|
self.driver.get(f"http://{self.host}:9009")
|
||||||
|
|
||||||
|
self.driver.switch_to.window(login_window)
|
||||||
|
self.login()
|
||||||
|
self.driver.switch_to.window(client_window)
|
||||||
|
|
||||||
|
self.wait_for_url(f"http://{self.host}:9009/")
|
||||||
|
|
||||||
|
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
|
||||||
|
[self.user.name],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"][
|
||||||
|
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
|
||||||
|
],
|
||||||
|
[self.user.username],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
|
||||||
|
[self.user.username],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
|
||||||
|
[str(self.user.pk)],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
|
||||||
|
[self.user.email],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
|
||||||
|
[self.user.email],
|
||||||
|
)
|
||||||
|
|||||||
20
uv.lock
generated
20
uv.lock
generated
@ -162,7 +162,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "authentik"
|
name = "authentik"
|
||||||
version = "2025.2.2"
|
version = "2025.2.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
@ -310,7 +310,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic-scim" },
|
{ name = "pydantic-scim" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pyrad" },
|
{ name = "pyrad" },
|
||||||
{ name = "python-kadmin-rs", specifier = "==0.5.3" },
|
{ name = "python-kadmin-rs", specifier = "==0.6.0" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "requests-oauthlib" },
|
{ name = "requests-oauthlib" },
|
||||||
{ name = "scim2-filter-parser" },
|
{ name = "scim2-filter-parser" },
|
||||||
@ -2599,16 +2599,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-kadmin-rs"
|
name = "python-kadmin-rs"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/95/07b708623f13874ad86dc603f2fe36e980a5f5890edea87286d13f2b0b81/python_kadmin_rs-0.5.3.tar.gz", hash = "sha256:4f46fd854af622896136c3ac4fc5e6a37d37bfffb5b2023e438001ffa62ab7e3", size = 89865 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/ac/df3a093b1e186cd68a6f38778fac025450e5c5e9859c4790e00c2ed0ff62/python_kadmin_rs-0.6.0.tar.gz", hash = "sha256:dadd3d4ef542b829c1dcde97360a6b6a10700a4b5686f12f24b10f6cf5ca6e6c", size = 89318 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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/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/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/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/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/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/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/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/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/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/9a/65/705f179cf4bf4d16fc1daeac0810def57da2f4514a5b79ca60f24d7efb90/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6799a0faddb4ccf200acfa87da38e5fa2af54970d066b2c876e752bbf794b204", size = 1590360 },
|
{ 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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
206
web/authentication/index.js
Normal file
206
web/authentication/index.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -48,6 +48,9 @@ export default [
|
|||||||
"lit/no-template-bind": "error",
|
"lit/no-template-bind": "error",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
|
"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/ban-ts-comment": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
@ -71,8 +74,18 @@ export default [
|
|||||||
...globals.node,
|
...globals.node,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
|
files: [
|
||||||
|
// TODO:Remove after project-wide ESLint config is properly set up.
|
||||||
|
"scripts/**/*.mjs",
|
||||||
|
"authentication/**/*.js",
|
||||||
|
"sfe/**/*.js",
|
||||||
|
"*.ts",
|
||||||
|
"*.mjs",
|
||||||
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
// TODO: TypeScript already handles this.
|
||||||
|
// Remove after project-wide ESLint config is properly set up.
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
// We WANT our scripts to output to the console!
|
// We WANT our scripts to output to the console!
|
||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
|
|||||||
2028
web/package-lock.json
generated
2028
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@
|
|||||||
"@floating-ui/dom": "^1.6.11",
|
"@floating-ui/dom": "^1.6.11",
|
||||||
"@formatjs/intl-listformat": "^7.5.7",
|
"@formatjs/intl-listformat": "^7.5.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@goauthentik/api": "^2025.2.2-1742585853",
|
"@goauthentik/api": "^2025.2.3-1743464496",
|
||||||
"@lit-labs/ssr": "^3.2.2",
|
"@lit-labs/ssr": "^3.2.2",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lit/localize": "^0.12.2",
|
"@lit/localize": "^0.12.2",
|
||||||
@ -57,9 +57,14 @@
|
|||||||
"ts-pattern": "^5.4.0",
|
"ts-pattern": "^5.4.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"webcomponent-qr-code": "^1.2.0",
|
"webcomponent-qr-code": "^1.2.0",
|
||||||
"yaml": "^2.5.1"
|
"yaml": "^2.5.1",
|
||||||
|
"bootstrap": "^4.6.1",
|
||||||
|
"formdata-polyfill": "^4.0.10",
|
||||||
|
"jquery": "^3.7.1",
|
||||||
|
"weakmap-polyfill": "^2.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jquery": "^3.5.31",
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
"@hcaptcha/types": "^1.0.4",
|
"@hcaptcha/types": "^1.0.4",
|
||||||
"@lit/localize-tools": "^0.8.0",
|
"@lit/localize-tools": "^0.8.0",
|
||||||
@ -90,6 +95,8 @@
|
|||||||
"@wdio/spec-reporter": "^9.1.2",
|
"@wdio/spec-reporter": "^9.1.2",
|
||||||
"chromedriver": "^131.0.1",
|
"chromedriver": "^131.0.1",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"esbuild-plugin-copy": "^2.1.1",
|
||||||
|
"esbuild-plugin-es5": "^2.1.1",
|
||||||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||||
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
|
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
@ -161,6 +168,12 @@
|
|||||||
"watch": "run-s build-locales esbuild:watch"
|
"watch": "run-s build-locales esbuild:watch"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./paths": "./paths.js",
|
||||||
|
"./authentication": "./authentication/index.js",
|
||||||
|
"./scripts/*": "./scripts/*.mjs"
|
||||||
|
},
|
||||||
"wireit": {
|
"wireit": {
|
||||||
"build": {
|
"build": {
|
||||||
"#comment": [
|
"#comment": [
|
||||||
@ -193,8 +206,7 @@
|
|||||||
"./dist/patternfly.min.css"
|
"./dist/patternfly.min.css"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"build-locales",
|
"build-locales"
|
||||||
"./packages/sfe:build"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_RUNNER": {
|
"NODE_RUNNER": {
|
||||||
@ -204,12 +216,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build:sfe": {
|
"build:sfe": {
|
||||||
"dependencies": [
|
"command": "node scripts/build-sfe.mjs"
|
||||||
"./packages/sfe:build"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"./packages/sfe/**/*.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"build-proxy": {
|
"build-proxy": {
|
||||||
"command": "node scripts/build-web.mjs --proxy",
|
"command": "node scripts/build-web.mjs --proxy",
|
||||||
@ -242,11 +249,6 @@
|
|||||||
"lint:package"
|
"lint:package"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"format:packages": {
|
|
||||||
"dependencies": [
|
|
||||||
"./packages/sfe:prettier"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lint": {
|
"lint": {
|
||||||
"command": "eslint --max-warnings 0 --fix",
|
"command": "eslint --max-warnings 0 --fix",
|
||||||
"env": {
|
"env": {
|
||||||
@ -274,11 +276,6 @@
|
|||||||
"shell": true,
|
"shell": true,
|
||||||
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
||||||
},
|
},
|
||||||
"lint:lockfiles": {
|
|
||||||
"dependencies": [
|
|
||||||
"./packages/sfe:lint:lockfile"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lint:package": {
|
"lint:package": {
|
||||||
"command": "syncpack format -i ' '"
|
"command": "syncpack format -i ' '"
|
||||||
},
|
},
|
||||||
@ -314,9 +311,7 @@
|
|||||||
"lint:spelling",
|
"lint:spelling",
|
||||||
"lint:package",
|
"lint:package",
|
||||||
"lint:lockfile",
|
"lint:lockfile",
|
||||||
"lint:lockfiles",
|
"lint:precommit"
|
||||||
"lint:precommit",
|
|
||||||
"format:packages"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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.
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@ -1,527 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["jquery"],
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"lib": ["DOM", "ES2015", "ES2017"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
web/paths.js
Normal file
25
web/paths.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @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")
|
||||||
|
);
|
||||||
90
web/scripts/build-sfe.mjs
Normal file
90
web/scripts/build-sfe.mjs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { deepmerge } from "deepmerge-ts";
|
import { deepmerge } from "deepmerge-ts";
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
@ -170,7 +171,7 @@ function composeVersionID() {
|
|||||||
* @throws {Error} on build failure
|
* @throws {Error} on build failure
|
||||||
*/
|
*/
|
||||||
function createEntryPointOptions([source, dest], overrides = {}) {
|
function createEntryPointOptions([source, dest], overrides = {}) {
|
||||||
const outdir = path.join(__dirname, "..", "dist", dest);
|
const outdir = path.join(DistDirectory, dest);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {esbuild.BuildOptions}
|
* @type {esbuild.BuildOptions}
|
||||||
@ -233,7 +234,7 @@ async function doWatch() {
|
|||||||
buildObserverPlugin({
|
buildObserverPlugin({
|
||||||
serverURL,
|
serverURL,
|
||||||
logPrefix: entryPoint[1],
|
logPrefix: entryPoint[1],
|
||||||
relativeRoot: path.join(__dirname, ".."),
|
relativeRoot: PackageRoot,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
|
|||||||
191
web/sfe/lib/AuthenticatorValidateStage.js
Normal file
191
web/sfe/lib/AuthenticatorValidateStage.js
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
35
web/sfe/lib/AutosubmitStage.js
Normal file
35
web/sfe/lib/AutosubmitStage.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
web/sfe/lib/IdentificationStage.js
Normal file
50
web/sfe/lib/IdentificationStage.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
web/sfe/lib/PasswordStage.js
Normal file
37
web/sfe/lib/PasswordStage.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
14
web/sfe/lib/RedirectStage.js
Normal file
14
web/sfe/lib/RedirectStage.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
web/sfe/lib/SimpleFlowExecutor.js
Normal file
113
web/sfe/lib/SimpleFlowExecutor.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
web/sfe/lib/Stage.js
Normal file
116
web/sfe/lib/Stage.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @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");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
web/sfe/lib/index.js
Normal file
12
web/sfe/lib/index.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @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";
|
||||||
20
web/sfe/lib/utils.js
Normal file
20
web/sfe/lib/utils.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user