Compare commits

..

3 Commits

Author SHA1 Message Date
bf9ac25740 makemigrations
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-07-04 17:56:01 +02:00
95cd7ec904 Update authentik/outposts/api/outposts.py
Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-07-04 17:00:10 +02:00
3985725550 outposts: allow force refresh
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-07-04 15:55:33 +02:00
61 changed files with 320 additions and 4622 deletions

View File

@ -21,10 +21,7 @@ updates:
labels:
- dependencies
- package-ecosystem: npm
directories:
- "/web"
- "/tests/wdio"
- "/web/sfe"
directory: "/web"
schedule:
interval: daily
time: "04:00"
@ -33,6 +30,7 @@ updates:
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
@ -58,6 +56,38 @@ updates:
patterns:
- "@rollup/*"
- "rollup-*"
- package-ecosystem: npm
directory: "/tests/wdio"
schedule:
interval: daily
time: "04:00"
labels:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@typescript-eslint/*"
- "eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
esbuild:
patterns:
- "@esbuild/*"
wdio:
patterns:
- "@wdio/*"

View File

@ -31,12 +31,7 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Upgrade /web
working-directory: web
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/sfe
working-directory: web/sfe
working-directory: web/
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION

View File

@ -26,10 +26,6 @@ jobs:
- web
- tests/wdio
include:
- command: tsc
project: web
extra_setup: |
cd sfe/ && npm ci
- command: lit-analyse
project: web
extra_setup: |

View File

@ -30,12 +30,8 @@ WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \
--mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev && \
cd sfe && \
npm ci --include=dev
COPY ./package.json /work
@ -43,9 +39,7 @@ COPY ./web /work/web/
COPY ./website /work/website/
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build && \
cd sfe && \
npm run build
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder

View File

@ -24,7 +24,7 @@ from authentik.tenants.utils import get_current_tenant
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True, allow_null=True)
href = CharField(read_only=True)
name = CharField(read_only=True)

View File

@ -1,99 +0,0 @@
"""User directory API Views"""
from typing import Any
from drf_spectacular.utils import extend_schema, inline_serializer
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import SerializerMethodField
from rest_framework.serializers import CharField, DictField, ListField, ModelSerializer
from rest_framework.views import Request, Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.rbac.permissions import HasPermission
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
class UserDirectorySerializer(ModelSerializer):
"""User Directory Serializer"""
user_fields = SerializerMethodField()
attributes = SerializerMethodField()
class Meta:
model = User
fields = [
"pk",
"user_fields",
"attributes",
]
def get_user_fields(self, obj: User) -> dict[str, Any]:
"""Get directory fields"""
fields = {}
user_directory_fields = get_current_tenant().user_directory_fields
for f in ("name", "username", "email", "avatar"):
if f in user_directory_fields:
fields[f] = getattr(obj, f)
if "groups" in user_directory_fields:
fields["groups"] = [g.name for g in obj.all_groups().order_by("name")]
return fields
def get_attributes(self, obj: User) -> dict[str, Any]:
"""Get directory attributes"""
attributes = {}
for field in get_current_tenant().user_directory_attributes:
path = field.get("attribute", None)
if path is not None:
attributes[path] = obj.attributes.get(path, None)
return attributes
class UserDirectoryViewSet(ReadOnlyModelViewSet):
"""User Directory Viewset"""
queryset = User.objects.none()
ordering = ["username"]
ordering_fields = ["username", "email", "name"]
serializer_class = UserDirectorySerializer
permission_classes = [HasPermission("authentik_rbac.view_user_directory")]
def get_queryset(self):
return User.objects.all().exclude(pk=get_anonymous_user().pk).filter(is_active=True)
@property
def search_fields(self):
"""Get search fields"""
current_tenant = get_current_tenant()
return list(
f for f in current_tenant.user_directory_fields if f not in ("avatar", "groups")
) + list(
f"attributes__{attr['attribute']}"
for attr in current_tenant.user_directory_attributes
if "attribute" in attr
)
@extend_schema(
responses={
200: inline_serializer(
"UserDirectoryFieldsSerializer",
{
"fields": ListField(child=CharField()),
"attributes": ListField(child=DictField(child=CharField())),
},
)
},
)
@action(detail=False, pagination_class=None)
def fields(self, request: Request) -> Response:
"""Get user directory fields"""
return Response(
{
"fields": request.tenant.user_directory_fields,
"attributes": request.tenant.user_directory_attributes,
}
)

View File

@ -309,7 +309,7 @@ class SourceFlowManager:
# When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated:
return self.handle_auth(connection)
connection.save()
# Connection has already been saved
Event.new(
EventAction.SOURCE_LINKED,
message="Linked Source",

View File

@ -10,7 +10,7 @@
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
};
window.addEventListener("DOMContentLoaded", function () {
window.addEventListener("DOMContentLoaded", () => {
{% for message in messages %}
window.dispatchEvent(
new CustomEvent("ak-message", {

View File

@ -71,9 +71,9 @@
</li>
{% endfor %}
<li>
<span>
<a rel="noopener noreferrer" target="_blank" href="https://goauthentik.io?utm_source=authentik">
{% trans 'Powered by authentik' %}
</span>
</a>
</li>
</ul>
</footer>

View File

@ -17,13 +17,11 @@ from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.user_directory import UserDirectoryViewSet
from authentik.core.api.users import UserViewSet
from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import InterfaceView
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware
@ -55,8 +53,6 @@ urlpatterns = [
),
path(
"if/flow/<slug:flow_slug>/",
# FIXME: move this url to the flows app...also will cause all
# of the reverse calls to be adjusted
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
@ -83,7 +79,6 @@ api_urlpatterns = [
),
("core/groups", GroupViewSet),
("core/users", UserViewSet),
("core/user_directory", UserDirectoryViewSet),
("core/tokens", TokenViewSet),
("sources/all", SourceViewSet),
("sources/user_connections/all", UserSourceConnectionViewSet),

View File

@ -3,6 +3,7 @@
from json import dumps
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from rest_framework.request import Request
@ -10,6 +11,7 @@ from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.flows.models import Flow
class InterfaceView(TemplateView):
@ -23,3 +25,14 @@ class InterfaceView(TemplateView):
kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs
return super().get_context_data(**kwargs)
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
template_name = "if/flow.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -1,54 +0,0 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon }}">
{% block head_before %}
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">
<meta name="sentry-trace" content="{{ sentry_trace }}" />
{% include "base/header_js.html" %}
<style>
html,
body {
height: 100%;
}
body {
background-image: url("{{ flow.background_url }}");
background-repeat: no-repeat;
background-size: cover;
}
.card {
padding: 3rem;
}
.form-signin {
max-width: 330px;
padding: 1rem;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.brand-icon {
max-width: 100%;
}
</style>
</head>
<body class="d-flex align-items-center py-4 bg-body-tertiary">
<div class="card m-auto">
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
</main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div>
<script src="{% static 'dist/sfe/index.js' %}"></script>
</body>
</html>

View File

@ -1,41 +0,0 @@
"""Interface views"""
from typing import Any
from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)
def compat_needs_sfe(self) -> bool:
"""Check if we need to use the simplified flow executor for compatibility"""
ua = Parse(self.request.META.get("HTTP_USER_AGENT", ""))
if ua["user_agent"]["family"] == "IE":
return True
# Only use SFE for Edge 18 and older, after Edge 18 MS switched to chromium which supports
# the default flow executor
if (
ua["user_agent"]["family"] == "Edge"
and int(ua["user_agent"]["major"]) <= 18 # noqa: PLR2004
): # noqa: PLR2004
return True
# https://github.com/AzureAD/microsoft-authentication-library-for-objc
# Used by Microsoft Teams/Office on macOS, and also uses a very outdated browser engine
if "PKeyAuth" in ua["string"]:
return True
return False
def get_template_names(self) -> list[str]:
if self.compat_needs_sfe() or "sfe" in self.request.GET:
return ["if/flow-sfe.html"]
return ["if/flow.html"]

View File

@ -4,7 +4,8 @@ from dacite.core import from_dict
from dacite.exceptions import DaciteError
from django_filters.filters import ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, DateTimeField, SerializerMethodField
@ -20,7 +21,6 @@ from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSeri
from authentik.core.models import Provider
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.providers.rac.models import RACProvider
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME
from authentik.outposts.models import (
@ -30,9 +30,11 @@ from authentik.outposts.models import (
OutpostType,
default_outpost_config,
)
from authentik.outposts.tasks import outpost_send_update
from authentik.providers.ldap.models import LDAPProvider
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.radius.models import RadiusProvider
from authentik.rbac.decorators import permission_required
class OutpostSerializer(ModelSerializer):
@ -50,10 +52,6 @@ class OutpostSerializer(ModelSerializer):
service_connection_obj = ServiceConnectionSerializer(
source="service_connection", read_only=True
)
refresh_interval_s = SerializerMethodField()
def get_refresh_interval_s(self, obj: Outpost) -> int:
return int(timedelta_from_string(obj.config.refresh_interval).total_seconds())
def validate_name(self, name: str) -> str:
"""Validate name (especially for embedded outpost)"""
@ -89,8 +87,7 @@ class OutpostSerializer(ModelSerializer):
def validate_config(self, config) -> dict:
"""Check that the config has all required fields"""
try:
parsed = from_dict(OutpostConfig, config)
timedelta_string_validator(parsed.refresh_interval)
from_dict(OutpostConfig, config)
except DaciteError as exc:
raise ValidationError(f"Failed to validate config: {str(exc)}") from exc
return config
@ -105,7 +102,6 @@ class OutpostSerializer(ModelSerializer):
"providers_obj",
"service_connection",
"service_connection_obj",
"refresh_interval_s",
"token_identifier",
"config",
"managed",
@ -207,3 +203,18 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
"""Global default outpost config"""
host = self.request.build_absolute_uri("/")
return Response({"config": default_outpost_config(host)})
@permission_required(None, ["authentik_outposts.refresh_outpost"])
@extend_schema(
request=OpenApiTypes.NONE,
responses={
204: OpenApiResponse(description="Successfully refreshed outpost"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(detail=True, methods=["POST"])
def force_refresh(self, request: Request, pk: int) -> Response:
"""Force an outpost to refresh its configuration. Will also clear its cache."""
outpost: Outpost = self.get_object()
outpost_send_update(outpost)
return Response(status=204)

View File

@ -0,0 +1,21 @@
# Generated by Django 5.0.6 on 2024-07-04 15:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_outposts", "0021_alter_outpost_type"),
]
operations = [
migrations.AlterModelOptions(
name="outpost",
options={
"permissions": [("refresh_outpost", "Trigger an outpost refresh")],
"verbose_name": "Outpost",
"verbose_name_plural": "Outposts",
},
),
]

View File

@ -61,7 +61,6 @@ class OutpostConfig:
log_level: str = CONFIG.get("log_level")
object_naming_template: str = field(default="ak-outpost-%(name)s")
refresh_interval: str = "minutes=5"
container_image: str | None = field(default=None)
@ -425,6 +424,10 @@ class Outpost(SerializerModel, ManagedModel):
verbose_name = _("Outpost")
verbose_name_plural = _("Outposts")
permissions = [
("refresh_outpost", _("Trigger an outpost refresh")),
]
@dataclass
class OutpostState:

View File

@ -4,6 +4,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_rbac", "0003_alter_systempermission_options"),
]
@ -16,9 +17,6 @@ class Migration(migrations.Migration):
"managed": False,
"permissions": [
("view_system_info", "Can view system info"),
("view_system_tasks", "Can view system tasks"),
("view_user_directory", "Can view users in the user directory"),
("run_system_tasks", "Can run system tasks"),
("access_admin_interface", "Can access admin interface"),
("view_system_settings", "Can view system settings"),
("edit_system_settings", "Can edit system settings"),

View File

@ -67,9 +67,6 @@ class SystemPermission(models.Model):
verbose_name_plural = _("System permissions")
permissions = [
("view_system_info", _("Can view system info")),
("view_system_tasks", _("Can view system tasks")),
("view_user_directory", _("Can view users in the user directory")),
("run_system_tasks", _("Can run system tasks")),
("access_admin_interface", _("Can access admin interface")),
("view_system_settings", _("Can view system settings")),
("edit_system_settings", _("Can edit system settings")),

View File

@ -325,7 +325,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
serializer = SelectableStageSerializer(
data={
"pk": stage.pk,
"name": getattr(stage, "friendly_name", stage.name),
"name": stage.friendly_name or stage.name,
"verbose_name": str(stage._meta.verbose_name)
.replace("Setup Stage", "")
.strip(),

View File

@ -23,8 +23,6 @@ class SettingsSerializer(ModelSerializer):
"footer_links",
"gdpr_compliance",
"impersonation",
"user_directory_fields",
"user_directory_attributes",
"default_token_duration",
"default_token_length",
]

View File

@ -1,30 +0,0 @@
# Generated by Django 5.0.1 on 2024-01-24 14:27
from django.db import migrations, models
import authentik.tenants.models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="user_directory_attributes",
field=models.JSONField(
blank=True, default=list, help_text="Attributes to show in the user directory."
),
),
migrations.AddField(
model_name="tenant",
name="user_directory_fields",
field=models.JSONField(
blank=True,
default=authentik.tenants.models._default_user_directory_fields,
help_text="Fields to show in the user directory.",
),
),
]

View File

@ -1,13 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-24 18:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0002_tenant_user_directory_and_more"),
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
]
operations = []

View File

@ -37,10 +37,6 @@ def _validate_schema_name(name):
)
def _default_user_directory_fields():
return ["avatar", "name", "username", "email", "groups"]
class Tenant(TenantMixin, SerializerModel):
"""Tenant"""
@ -89,14 +85,6 @@ class Tenant(TenantMixin, SerializerModel):
impersonation = models.BooleanField(
help_text=_("Globally enable/disable impersonation."), default=True
)
user_directory_fields = models.JSONField(
help_text=_("Fields to show in the user directory."),
default=_default_user_directory_fields,
blank=True,
)
user_directory_attributes = models.JSONField(
help_text=_("Attributes to show in the user directory."), default=list, blank=True
)
default_token_duration = models.TextField(
help_text=_("Default token duration"),
default=DEFAULT_TOKEN_DURATION,

2
go.mod
View File

@ -28,7 +28,7 @@ require (
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2024060.5
goauthentik.io/api/v3 v3.2024060.3
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.21.0
golang.org/x/sync v0.7.0

4
go.sum
View File

@ -294,8 +294,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.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
goauthentik.io/api/v3 v3.2024060.5 h1:AjvPUZoObk7a86ZZaz2tmruteY+1vAEfVzIOzQpWSXM=
goauthentik.io/api/v3 v3.2024060.5/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2024060.3 h1:pdbz4a7p6KsuzKtRI/zqXGT6tPP3MvUuvwLaCv/8XpA=
goauthentik.io/api/v3 v3.2024060.3/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -183,7 +183,7 @@ func (ac *APIController) startWSHealth() {
func (ac *APIController) startIntervalUpdater() {
logger := ac.logger.WithField("loop", "interval-updater")
ticker := time.NewTicker(time.Duration(ac.Outpost.RefreshIntervalS) * time.Second)
ticker := time.NewTicker(5 * time.Minute)
for ; true; <-ticker.C {
logger.Debug("Running interval update")
err := ac.OnRefresh()
@ -198,7 +198,6 @@ func (ac *APIController) startIntervalUpdater() {
"build": constants.BUILD("tagged"),
}).SetToCurrentTime()
}
ticker.Reset(time.Duration(ac.Outpost.RefreshIntervalS) * time.Second)
}
}

View File

@ -48,9 +48,9 @@
<footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline">
<li>
<span>
<a rel="noopener noreferrer" target="_blank" href="https://goauthentik.io?utm_source=authentik_outpost&utm_campaign=proxy_error">
Powered by authentik
</span>
</a>
</li>
</ul>
</footer>

View File

@ -4537,119 +4537,6 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/:
get:
operationId: core_user_directory_list
description: User Directory Viewset
parameters:
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedUserDirectoryList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/{id}/:
get:
operationId: core_user_directory_retrieve
description: User Directory Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserDirectory'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/fields/:
get:
operationId: core_user_directory_fields_retrieve
description: Get user directory fields
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserDirectoryFields'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/:
get:
operationId: core_users_list
@ -9545,6 +9432,34 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/outposts/instances/{uuid}/force_refresh/:
post:
operationId: outposts_instances_force_refresh_create
description: Force an outpost to refresh its configuration. Will also clear
its cache.
parameters:
- in: path
name: uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Outpost.
required: true
tags:
- outposts
security:
- authentik: []
responses:
'204':
description: Successfully refreshed outpost
'400':
description: Bad request
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/outposts/instances/{uuid}/health/:
get:
operationId: outposts_instances_health_list
@ -36747,7 +36662,6 @@ components:
href:
type: string
readOnly: true
nullable: true
name:
type: string
readOnly: true
@ -39611,9 +39525,6 @@ components:
allOf:
- $ref: '#/components/schemas/ServiceConnection'
readOnly: true
refresh_interval_s:
type: integer
readOnly: true
token_identifier:
type: string
description: Get Token identifier
@ -39635,7 +39546,6 @@ components:
- pk
- providers
- providers_obj
- refresh_interval_s
- service_connection_obj
- token_identifier
- type
@ -40972,18 +40882,6 @@ components:
required:
- pagination
- results
PaginatedUserDirectoryList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/UserDirectory'
required:
- pagination
- results
PaginatedUserList:
type: object
properties:
@ -43739,10 +43637,6 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
minLength: 1
@ -47030,10 +46924,6 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
description: Default token duration
@ -47073,10 +46963,6 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
minLength: 1
@ -48169,44 +48055,6 @@ components:
$ref: '#/components/schemas/FlowSetRequest'
required:
- name
UserDirectory:
type: object
description: User Directory Serializer
properties:
pk:
type: integer
readOnly: true
title: ID
user_fields:
type: object
additionalProperties: {}
description: Get directory fields
readOnly: true
attributes:
type: object
additionalProperties: {}
description: Get directory attributes
readOnly: true
required:
- attributes
- pk
- user_fields
UserDirectoryFields:
type: object
properties:
fields:
type: array
items:
type: string
attributes:
type: array
items:
type: object
additionalProperties:
type: string
required:
- attributes
- fields
UserFieldsEnum:
enum:
- email

View File

@ -1,6 +1,5 @@
"""test OAuth Source"""
from json import loads
from pathlib import Path
from time import sleep
from typing import Any
@ -195,41 +194,3 @@ class TestSourceOAuth2(SeleniumTestCase):
self.driver.get(self.if_user_url("/settings"))
self.assert_user(User(username="foo", name="admin", email="admin@example.com"))
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-source-authentication.yaml",
"default/flow-default-source-enrollment.yaml",
"default/flow-default-source-pre-authentication.yaml",
)
def test_oauth_link(self):
"""test OAuth Source link OIDC"""
self.create_objects()
self.driver.get(self.live_server_url)
self.login()
self.driver.get(
self.url("authentik_sources_oauth:oauth-client-login", source_slug=self.slug)
)
# Now we should be at the IDP, wait for the login field
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
self.driver.find_element(By.ID, "password").send_keys("password")
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
# Wait until we're logged in
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]")))
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
self.driver.get(self.url("authentik_api:usersourceconnection-list") + "?format=json")
body_json = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
results = body_json["results"]
self.assertEqual(len(results), 1)
connection = results[0]
self.assertEqual(connection["source"]["slug"], self.slug)
self.assertEqual(connection["user"], self.user.pk)

106
web/package-lock.json generated
View File

@ -18,7 +18,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.5.2",
"@goauthentik/api": "^2024.6.0-1720200294",
"@goauthentik/api": "^2024.6.0-1720092601",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.1",
"@lit/reactive-element": "^2.0.4",
@ -26,7 +26,7 @@
"@open-wc/lit-helpers": "^0.7.0",
"@patternfly/elements": "^3.0.2",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^8.15.0",
"@sentry/browser": "^8.13.0",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"chart.js": "^4.4.3",
@ -2798,9 +2798,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2024.6.0-1720200294",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.0-1720200294.tgz",
"integrity": "sha512-qGpI+0BpsHWlO8waj89q+6SWjVVuRtYqdmpSIrKFsZt9GLNXCvIAvgS5JI1Sq2z1uWK/8kLNZKDocI/XagqMPQ=="
"version": "2024.6.0-1720092601",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.0-1720092601.tgz",
"integrity": "sha512-TBs/s/kmXRxs4scmAZSXSgyRR6IFtDvDmW4VHl7REehumkspCKvguyn/IasFYIAhS2ElIx8ORmhzJBpEy7PMOg=="
},
"node_modules/@hcaptcha/types": {
"version": "1.0.3",
@ -5229,102 +5229,102 @@
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.15.0.tgz",
"integrity": "sha512-DquySUQRnmMyRbfHH9t/JDwPMsVaCOCzV/6XmMb7s4FDYTWSCSpumJEYfiyJCuI9NeebPHZWF7LZCHH4glSAJQ==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.13.0.tgz",
"integrity": "sha512-lqq8BYbbs9KTlDuyB5NjdZB6P/llqQs32KUgaCQ/k5DFB4Zf56+BFHXObnMHxwx375X1uixtnEphagWZa+nsLQ==",
"dependencies": {
"@sentry/core": "8.15.0",
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry/core": "8.13.0",
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.15.0.tgz",
"integrity": "sha512-W6XiLpw7fL1A0KaHxIH45nbC2M8uagrMoBnMZ1NcqE4AoSe7VtoDqPsLvQ7MgMXwsBYiPu2AItRnKoGFS/dUBA==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.13.0.tgz",
"integrity": "sha512-YyJ6SzpTonixvguAg0H9vkEp7Jq8ZeVY8M4n47ClR0+TtaAUp04ZhcJpHKF7PwBIAzc7DRr2XP112tmWgiVEcg==",
"dependencies": {
"@sentry/core": "8.15.0",
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry/core": "8.13.0",
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.15.0.tgz",
"integrity": "sha512-d4cA8pjr0CGHkTe8ulqMROYCX3bMHBAi/7DJBr11i4MdNCUl+/pndA9C5TiFv0sFzk/hDZQZS3J+MfGp56ZQHw==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.13.0.tgz",
"integrity": "sha512-DJ1jF/Pab0FH4SeCvSGCnGAu/s0wJvhBWM5VjQp7Jjmcfunp+R3vJibqU8gAVZU1nYRLaqprLdIXrSyP2Km8nQ==",
"dependencies": {
"@sentry-internal/browser-utils": "8.15.0",
"@sentry/core": "8.15.0",
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry-internal/browser-utils": "8.13.0",
"@sentry/core": "8.13.0",
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.15.0.tgz",
"integrity": "sha512-gfezIuvf94wY748l5wSy4pRm+45GjiBm0Q/KLnXROLZKmbI7MTJrdQXA2Oxut848iISTQo4/LimecFnBDiaGtw==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.13.0.tgz",
"integrity": "sha512-lPlfWVIHX+gW4S8a/UOVutuqMyQhlkNUAay0W21MVhZJT5Mtj0p21D/Cz7nrOQRDIiLNq90KAGK2tLxx5NkiWA==",
"dependencies": {
"@sentry-internal/replay": "8.15.0",
"@sentry/core": "8.15.0",
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry-internal/replay": "8.13.0",
"@sentry/core": "8.13.0",
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/browser": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.15.0.tgz",
"integrity": "sha512-Tx4eFgAqa8tedg30+Cgr7qFocWHise8p3jb/RSNs+TCEBXLVtQidHHVZMO71FWUAC86D7woo5hMKTt+UmB8pgg==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.13.0.tgz",
"integrity": "sha512-/tp7HZ5qjwDLtwooPMoexdAi2PG7gMNY0bHeMlwy20hs8mclC8RW8ZiJA6czXHfgnbmvxfrHaY53IJyz//JnlA==",
"dependencies": {
"@sentry-internal/browser-utils": "8.15.0",
"@sentry-internal/feedback": "8.15.0",
"@sentry-internal/replay": "8.15.0",
"@sentry-internal/replay-canvas": "8.15.0",
"@sentry/core": "8.15.0",
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry-internal/browser-utils": "8.13.0",
"@sentry-internal/feedback": "8.13.0",
"@sentry-internal/replay": "8.13.0",
"@sentry-internal/replay-canvas": "8.13.0",
"@sentry/core": "8.13.0",
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/core": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.15.0.tgz",
"integrity": "sha512-RjuEq/34VjNmxlfzq+485jG63/Vst90svQapLwVgBZWgM8jxrLyCRXHU0wfBc7/1IhV/T9GYAplrJQAkG4J9Ow==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.13.0.tgz",
"integrity": "sha512-N9Qg4ZGxZWp8eb2eUUHVVKgjBLtFIjS805nG92s6yJmkvOpKm6mLtcUaT/iDf3Hta6nG+xRkhbE3r+Z4cbXG8w==",
"dependencies": {
"@sentry/types": "8.15.0",
"@sentry/utils": "8.15.0"
"@sentry/types": "8.13.0",
"@sentry/utils": "8.13.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/types": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.15.0.tgz",
"integrity": "sha512-AZc9nSHKuNH8P/7ihmq5fbZBiQ7Gr35kJq9Tad9eVuOgL8D+2b6Vqu/61ljVVlMFI0tBGFsSkWJ/00PfBcVKWg==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.13.0.tgz",
"integrity": "sha512-r63s/H5gvQnQM9tTGBXz2xErUbxZALh4e2Lg/1aHj4zIvGLBjA2z5qWsh6TEZYbpmgAyGShLDr6+rWeUVf9yBQ==",
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/utils": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.15.0.tgz",
"integrity": "sha512-1ISmyYFuRHJbGun0gUYscyz1aP6RfILUldNAUwQwF0Ycu8YOi4n8uwJRN0aov6cCi41tnZWOMBagSeLxbJiJgQ==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.13.0.tgz",
"integrity": "sha512-PxV0v9VbGWH9zP37P5w2msLUFDr287nYjoY2XVF+RSolyiTs1CQNI5ZMUO3o4MsSac/dpXxjyrZXQd72t/jRYA==",
"dependencies": {
"@sentry/types": "8.15.0"
"@sentry/types": "8.13.0"
},
"engines": {
"node": ">=14.18"

View File

@ -43,7 +43,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.5.2",
"@goauthentik/api": "^2024.6.0-1720200294",
"@goauthentik/api": "^2024.6.0-1720092601",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.1",
"@lit/reactive-element": "^2.0.4",
@ -51,7 +51,7 @@
"@open-wc/lit-helpers": "^0.7.0",
"@patternfly/elements": "^3.0.2",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^8.15.0",
"@sentry/browser": "^8.13.0",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"chart.js": "^4.4.3",

View File

@ -1,529 +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;
};
}
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 `/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");
}
}
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>
Login 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());
const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, {
challenge,
user,
});
return transformedCredentialCreateOptions;
}
/**
* 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 });
},
);
const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, {
challenge,
allowCredentials,
});
return transformedCredentialRequestOptions;
}
/**
* 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) => {
if (challenge.deviceClass === "webauthn") {
if (!this.checkWebAuthnSupport()) {
return undefined;
}
}
return 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();

3057
web/sfe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +0,0 @@
{
"name": "@goauthentik/web-sfe",
"version": "0.0.0",
"private": true,
"license": "MIT",
"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"
},
"scripts": {
"build": "rollup -c rollup.config.js --bundleConfigAsCjs",
"watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-swc": "^0.3.1",
"@swc/cli": "^0.3.14",
"@swc/core": "^1.6.7",
"@types/jquery": "^3.5.30",
"rollup": "^4.18.0",
"rollup-plugin-copy": "^3.5.0"
}
}

View File

@ -1,40 +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: "index.ts",
output: {
dir: "../dist/sfe",
format: "cjs",
},
context: "window",
plugins: [
copy({
targets: [
{ src: "node_modules/bootstrap/dist/css/bootstrap.min.css", dest: "../dist/sfe" },
],
}),
resolve({ browser: true }),
commonjs(),
swc({
swc: {
jsc: {
loose: false,
externalHelpers: false,
// Requires v1.2.50 or upper and requires target to be es2016 or upper.
keepClassNames: false,
},
minify: false,
env: {
targets: {
edge: "17",
ie: "11",
},
mode: "entry",
},
},
}),
],
};

View File

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

View File

@ -193,42 +193,6 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
help=${msg("Globally enable/disable impersonation.")}
>
</ak-switch-input>
<ak-form-element-horizontal
label=${msg("User directory fields")}
name="userDirectoryFields"
>
<ak-codemirror
mode=${CodeMirrorMode.YAML}
.value="${first(this._settings?.userDirectoryFields, [
"name",
"username",
"email",
"avatars",
"groups",
])}"
></ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"This option configures what user fields are shown in the user directory. It must be a valid JSON list and can be used as follows, with all possible values included:",
)}
<code>["name", "username", "email", "avatars", "groups"]</code>
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User directory attributes")}
name="userDirectoryAttributes"
>
<ak-codemirror
mode=${CodeMirrorMode.YAML}
.value="${first(this._settings?.userDirectoryAttributes, [])}"
></ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"This option configures what user attributes are shown in the user directory. It must be a valid JSON list and can be used as follows:",
)}
<code>[{"attribute": "phone_number", "display_name": "Phone"}]</code>
</p>
</ak-form-element-horizontal>
<ak-text-input
name="defaultTokenDuration"
label=${msg("Default token duration")}

View File

@ -53,7 +53,7 @@ export class BlueprintListPage extends TablePage<BlueprintInstance> {
return msg("Automate and template configuration within authentik.");
}
pageIcon(): string {
return "fa fa-user";
return "pf-icon pf-icon-blueprint";
}
expandable = true;

View File

@ -158,7 +158,29 @@ export class OutpostListPage extends TablePage<Outpost> {
${msg("View Deployment Info")}
</button>
</ak-outpost-deployment-modal>`
: html``}`,
: html``}
<ak-forms-confirm
successMessage=${msg("Successfully refreshed outpost")}
errorMessage=${msg("Failed to refresh outpost")}
action=${msg("Refresh configuration")}
.onConfirm=${() => {
return new OutpostsApi(DEFAULT_CONFIG).outpostsInstancesForceRefreshCreate({
uuid: item.pk,
});
}}
>
<span slot="header"> ${msg("Refresh outpost")} </span>
<p slot="body">
${msg(
`Are you sure you want to refresh this outpost?
This will cause the outpost cache to be cleared.`,
)}
</p>
<button slot="trigger" class="pf-c-button pf-m-secondary" type="button">
${msg("Refresh configuration")}
</button>
<div slot="modal"></div>
</ak-forms-confirm> `,
];
}

View File

@ -141,7 +141,6 @@ export class PageHeader extends WithBrandConfig(AKElement) {
return html` <ak-enterprise-status interface="admin"></ak-enterprise-status>
<div class="bar">
<button
part="sidebar-trigger"
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${() => {
this.dispatchEvent(

View File

@ -70,17 +70,14 @@ export abstract class TablePage<T> extends Table<T> {
</button>`;
}
renderPageHeader(): TemplateResult {
return html`<ak-page-header
icon=${this.pageIcon()}
header=${this.pageTitle()}
description=${ifDefined(this.pageDescription())}
>
</ak-page-header>`;
}
render(): TemplateResult {
return html`${this.renderPageHeader()} ${this.renderSectionBefore()}
return html`<ak-page-header
icon=${this.pageIcon()}
header=${this.pageTitle()}
description=${ifDefined(this.pageDescription())}
>
</ak-page-header>
${this.renderSectionBefore()}
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-sidebar pf-m-gutter">
<div class="pf-c-sidebar__main">

View File

@ -503,17 +503,19 @@ export class FlowExecutor extends Interface implements StageHost {
<footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline">
${this.brand?.uiFooterLinks?.map((link) => {
if (link.href) {
return html`<li>
<a href="${link.href}">${link.name}</a>
</li>`;
}
return html`<li>
<span>${link.name}</span>
<a href="${link.href || ""}"
>${link.name}</a
>
</li>`;
})}
<li>
<span>${msg("Powered by authentik")}</span>
<a
href="https://goauthentik.io?utm_source=authentik&amp;utm_medium=flow"
target="_blank"
rel="noopener noreferrer"
>${msg("Powered by authentik")}</a
>
</li>
</ul>
</footer>

View File

@ -8,10 +8,6 @@ export const ROUTES: Route[] = [
new Route(new RegExp("^/$")).redirect("/library"),
new Route(new RegExp("^#.*")).redirect("/library"),
new Route(new RegExp("^/library$"), async () => html`<ak-library></ak-library>`),
new Route(new RegExp("^/directory"), async () => {
await import("@goauthentik/user/user-directory/UserDirectoryPage");
return html`<ak-user-directory></ak-user-directory>`;
}),
new Route(new RegExp("^/settings$"), async () => {
await import("@goauthentik/user/user-settings/UserSettingsPage");
return html`<ak-user-settings></ak-user-settings>`;

View File

@ -159,13 +159,6 @@ class UserInterfacePresentation extends AKElement {
.otherwise(() => this.me.user.username);
}
get canAccessUserDirectory() {
return (
this.me.user.isSuperuser ||
this.me.user.systemPermissions.includes("can_view_user_directory")
);
}
get canAccessAdmin() {
return (
this.me.user.isSuperuser ||
@ -211,8 +204,6 @@ class UserInterfacePresentation extends AKElement {
<!-- -->
${this.renderNotificationDrawerTrigger()}
<!-- -->
${this.renderUserDirectory()}
<!-- -->
${this.renderSettings()}
<div class="pf-c-page__header-tools-item">
<a
@ -364,20 +355,6 @@ class UserInterfacePresentation extends AKElement {
</a>`;
}
renderUserDirectory() {
if (!this.canAccessUserDirectory) {
return nothing;
}
return html` <div class="pf-c-page__header-tools-item">
<a class="pf-c-button pf-m-plain" type="button" href="#/directory">
<pf-tooltip position="top" content=${msg("User directory")}>
<i class="pf-icon pf-icon-project" aria-hidden="true"></i>
</pf-tooltip>
</a>
</div>`;
}
renderImpersonation() {
if (!this.me.original) {
return nothing;

View File

@ -1,131 +0,0 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { renderDescriptionList } from "@goauthentik/components/DescriptionList.js";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, UserDirectory } from "@goauthentik/api";
const knownFields: Record<string, string> = {
avatar: "",
username: msg("Username"),
name: msg("Name"),
email: msg("Email"),
};
type UserFieldAttributes = { display_name: string; attribute: string };
@customElement("ak-user-directory")
export class UserDirectoryPage extends TablePage<UserDirectory> {
expandable = true;
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return msg("User Directory");
}
pageDescription(): string {
return msg("Display a list of users on this system.");
}
pageIcon(): string {
return "pf-icon pf-icon-project";
}
@property()
order = "username";
@state()
fields?: string[];
@state()
userFieldAttributes?: object[] = [];
static get styles() {
return [
...super.styles,
PFDescriptionList,
PFCard,
PFAlert,
PFAvatar,
css`
ak-page-header::part(sidebar-trigger) {
display: none;
}
`,
];
}
async apiEndpoint(): Promise<PaginatedResponse<UserDirectory>> {
const fields = await new CoreApi(DEFAULT_CONFIG).coreUserDirectoryFieldsRetrieve();
this.fields = fields.fields;
this.userFieldAttributes = fields.attributes;
return await new CoreApi(DEFAULT_CONFIG).coreUserDirectoryList(
await this.defaultEndpointConfig(),
);
}
columns() {
return (this.fields ?? [])
.filter((item) => item in knownFields)
.map((item) =>
item === "avatar"
? new TableColumn(knownFields[item])
: new TableColumn(knownFields[item], item),
);
}
row(item: UserDirectory) {
return (this.fields ?? [])
.filter((field: string) => Object.hasOwn(knownFields, field))
.map((field: string) =>
field !== "avatar"
? html`${item.userFields[field]}`
: html` <img
class="pf-c-avatar"
src=${item.userFields[field]}
alt="${msg("Avatar image")}"
/>`,
);
}
renderExpanded(item: UserDirectory) {
const groupDescription =
this.fields?.includes("groups") && (item.userFields["groups"] ?? []).length > 0
? [
[msg("Groups")],
item.userFields["groups"].map(
(group: string) => html`
<div class="pf-c-description-list__text">${group}</div>
`,
),
]
: [];
const userDescriptions = ((this.userFieldAttributes ?? []) as UserFieldAttributes[])
.filter(({ attribute }) => attribute !== null)
.map(({ display_name, attribute }) => [display_name, item.attributes[attribute]]);
const toShow = [...groupDescription, ...userDescriptions];
return toShow.length > 1
? html`<td role="cell" colspan="3">
<div class="pf-c-table__expandable-row-content">
${renderDescriptionList(toShow)}
</div>
</td>`
: html``;
}
}

View File

@ -1,4 +1,4 @@
<?xml version="1.0"?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<?xml version="1.0" ?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file target-language="zh-Hans" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="s4caed5b7a7e5d89b">
@ -596,9 +596,9 @@
</trans-unit>
<trans-unit id="saa0e2675da69651b">
<source>The URL "<x id="0" equiv-text="${this.url}"/>" was not found.</source>
<target>未找到 URL "
<x id="0" equiv-text="${this.url}"/>"。</target>
<source>The URL &quot;<x id="0" equiv-text="${this.url}"/>&quot; was not found.</source>
<target>未找到 URL &quot;
<x id="0" equiv-text="${this.url}"/>&quot;。</target>
</trans-unit>
<trans-unit id="s58cd9c2fe836d9c6">
@ -1040,8 +1040,8 @@
</trans-unit>
<trans-unit id="sa8384c9c26731f83">
<source>To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.</source>
<target>要允许任何重定向 URI请将此值设置为 ".*"。请注意这可能带来的安全影响。</target>
<source>To allow any redirect URI, set this value to &quot;.*&quot;. Be aware of the possible security implications this can have.</source>
<target>要允许任何重定向 URI请将此值设置为 &quot;.*&quot;。请注意这可能带来的安全影响。</target>
</trans-unit>
<trans-unit id="s55787f4dfcdce52b">
@ -1767,8 +1767,8 @@
</trans-unit>
<trans-unit id="sa90b7809586c35ce">
<source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".</source>
<target>输入完整 URL、相对路径或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。</target>
<source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon &quot;fa-test&quot;.</source>
<target>输入完整 URL、相对路径或者使用 'fa://fa-test' 来使用 Font Awesome 图标 &quot;fa-test&quot;。</target>
</trans-unit>
<trans-unit id="s0410779cb47de312">
@ -2946,8 +2946,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="s76768bebabb7d543">
<source>Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'</source>
<target>包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...'</target>
<source>Field which contains members of a group. Note that if using the &quot;memberUid&quot; field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'</source>
<target>包含组成员的字段。请注意,如果使用 &quot;memberUid&quot; 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...'</target>
</trans-unit>
<trans-unit id="s026555347e589f0e">
@ -3708,8 +3708,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="s7b1fba26d245cb1c">
<source>When using an external logging solution for archiving, this can be set to "minutes=5".</source>
<target>使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。</target>
<source>When using an external logging solution for archiving, this can be set to &quot;minutes=5&quot;.</source>
<target>使用外部日志记录解决方案进行存档时,可以将其设置为 &quot;minutes=5&quot;。</target>
</trans-unit>
<trans-unit id="s44536d20bb5c8257">
@ -3885,10 +3885,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="sa95a538bfbb86111">
<source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> "<x id="1" equiv-text="${this.obj?.name}"/>"?</source>
<source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> &quot;<x id="1" equiv-text="${this.obj?.name}"/>&quot;?</source>
<target>您确定要更新
<x id="0" equiv-text="${this.objectLabel}"/>"
<x id="1" equiv-text="${this.obj?.name}"/>" 吗?</target>
<x id="0" equiv-text="${this.objectLabel}"/>&quot;
<x id="1" equiv-text="${this.obj?.name}"/>&quot; 吗?</target>
</trans-unit>
<trans-unit id="sc92d7cfb6ee1fec6">
@ -4964,7 +4964,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="sdf1d8edef27236f0">
<source>A "roaming" authenticator, like a YubiKey</source>
<source>A &quot;roaming&quot; authenticator, like a YubiKey</source>
<target>像 YubiKey 这样的“漫游”身份验证器</target>
</trans-unit>
@ -5299,10 +5299,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="s2d5f69929bb7221d">
<source><x id="0" equiv-text="${prompt.name}"/> ("<x id="1" equiv-text="${prompt.fieldKey}"/>", of type <x id="2" equiv-text="${prompt.type}"/>)</source>
<source><x id="0" equiv-text="${prompt.name}"/> (&quot;<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;, of type <x id="2" equiv-text="${prompt.type}"/>)</source>
<target>
<x id="0" equiv-text="${prompt.name}"/>"
<x id="1" equiv-text="${prompt.fieldKey}"/>",类型为
<x id="0" equiv-text="${prompt.name}"/>&quot;
<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;,类型为
<x id="2" equiv-text="${prompt.type}"/></target>
</trans-unit>
@ -5351,7 +5351,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit>
<trans-unit id="s1608b2f94fa0dbd4">
<source>If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here.</source>
<source>If set to a duration above 0, the user will have the option to choose to &quot;stay signed in&quot;, which will extend their session by the time specified here.</source>
<target>如果设置时长大于 0用户可以选择“保持登录”选项这将使用户的会话延长此处设置的时间。</target>
</trans-unit>
@ -7790,7 +7790,7 @@ Bindings to groups/users are checked against the user of the event.</source>
<target>成功创建用户并添加到组 <x id="0" equiv-text="${this.group.name}"/></target>
</trans-unit>
<trans-unit id="s824e0943a7104668">
<source>This user will be added to the group "<x id="0" equiv-text="${this.targetGroup.name}"/>".</source>
<source>This user will be added to the group &quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&quot;.</source>
<target>此用户将会被添加到组 &amp;quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&amp;quot;。</target>
</trans-unit>
<trans-unit id="s62e7f6ed7d9cb3ca">
@ -8865,4 +8865,4 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
</body>
</file>
</xliff>
</xliff>

View File

@ -4,7 +4,7 @@ title: Flow executor (backend)
A big focus of authentik is the flows system, which allows you to combine and build complex conditional processes using stages and policies. Normally, these flows are automatically executed in the browser using authentik's [standard browser-based flow executor (/if/flows)](/docs/flow/executors/if-flow).
However, any flow can be executed via an API from anywhere, in fact that is what every flow executor does. With a few requests you can execute flows from anywhere, and integrate authentik even better.
However, any flow can be executed via an API from anywhere, in fact that is what the backend flow executor does. With a few requests you can execute flows from anywhere, and integrate authentik even better.
:::info
Because the flow executor stores its state in the HTTP Session, so you need to ensure that cookies between flow executor requests are persisted.

View File

@ -51,8 +51,6 @@ The setting can be used as follows:
[{ "name": "Link Name", "href": "https://goauthentik.io" }]
```
Starting with authentik 2024.6.1, the `href` attribute is optional, and this option can be used to add additional text to the flow executor pages.
### GDPR compliance
When enabled, all the events caused by a user will be deleted upon the user's deletion. Defaults to `true`.

View File

@ -6,6 +6,6 @@ The headless flow executor is used by clients that don't have access to the web
The following stages are supported:
- [**Identification stage**](../stages/identification/)
- [**Password stage**](../stages/password/)
- [**Authenticator Validation Stage**](../stages/authenticator_validate/)
- [**identification**](../stages/identification/)
- [**password**](../stages/password/)
- [**authenticator_validate**](../stages/authenticator_validate/)

View File

@ -1,9 +1,5 @@
---
title: Default
title: Default (Web)
---
This is the default, web-based environment that flows are executed in. All stages are compatible with this environment and no limitations are imposed.
:::info
All flow executors use the same [API](../../../developer-docs/api/flow-executor) which allows for the implementation of custom flow executors.
:::
This is the default, web-based environment flows are executed in. All stages are compatible with this environment and no limitations are imposed.

View File

@ -1,31 +0,0 @@
---
title: Simplified flow executor
---
<span class="badge badge--info">authentik 2024.8+</span>
A simplified web-based flow executor that authentik automatically uses for older browsers that do not support modern web technologies.
Currently this flow executor is automatically used for the following browsers:
- Internet Explorer
- Microsoft Edge (up to and including version 18)
The following stages are supported:
- [**Identification stage**](../stages/identification/)
:::info
Only user identifier and user identifier + password stage configurations are supported; sources and passwordless configurations are not supported.
:::
- [**Password stage**](../stages/password/)
- [**Authenticator Validation Stage**](../stages/authenticator_validate/)
Compared to the [default flow executor](./if-flow.md), this flow executor does _not_ support the following features:
- Localization
- Theming (Dark / light themes)
- Theming (Custom CSS)
- Stages not listed above
- Flow inspector

View File

@ -1,5 +1,5 @@
---
title: Authenticator validation stage
title: Authenticator Validation Stage
---
This stage validates an already configured Authenticator Device. This device has to be configured using any of the other authenticator stages:

View File

@ -3,11 +3,6 @@
# Allowed levels: trace, debug, info, warning, error
# Applies to: non-embedded
log_level: debug
# Interval at which the outpost will refresh the providers
# from authentik. For caching outposts (such as LDAP), the
# cache will also be invalidated at that interval.
# (Format: hours=1;minutes=2;seconds=3).
refresh_interval: minutes=5
########################################
# The settings below are only relevant when using a managed outpost
########################################

View File

@ -23,8 +23,6 @@ LDAP Property Mappings are used when you define a LDAP Source. These mappings de
These are configured with most common LDAP setups.
You can also configure [custom LDAP property mappings](../sources/ldap/index.md#custom-ldap-property-mapping).
## Scope Mapping
Scope Mappings are used by the OAuth2 Provider to map information from authentik to OAuth2/OpenID Claims. Values returned by a Scope Mapping are added as custom claims to Access and ID tokens.

View File

@ -73,20 +73,6 @@ By default, authentik ships with [pre-configured mappings](../../property-mappin
You can assign the value of a mapping to any user attribute, or save it as a custom attribute by prefixing the object field with `attribute.` Keep in mind though, data types from the LDAP server will be carried over. This means that with some implementations, where fields are stored as array in LDAP, they will be saved as array in authentik. To prevent this, use the built-in `list_flatten` function.
### Custom LDAP Property Mapping
If the default source mapping is not enough, you can set your own custom LDAP property mapping.
Here are the steps:
1. In authentik, open the Admin interface, and then navigate to **Customization -> Property Mappings**.
2. Click **Create**, select **LDAP Property Mapping**, and then click **Next**.
3. Type a unique and meaningful **Name**, such as `ldap-displayName-mapping:name`.
4. In the**Object field** field, type the name of an existing authentik field, such as `name`. If you want to add more extended attributes, you can type `attributes.mobile` for example.
5. In the **Expression** field enter Python expressions to retrieve the value from LDAP source. For example `return list_flatten(ldap.get("displayName"))`.
`list_flatten(["input string array"])` will convert a string array to a single string. If you are not sure whether the LDAP field is an array or not, you can map the field to any `attributes.xxx` and then check the sync result in authentik UI.
## Password login
By default, authentik doesn't update the password it stores for a user when they log in using their LDAP credentials. That means that if the LDAP server is not reachable by authentik, users will not be able to log in. This behavior can be turned on with the **Update internal password on login** setting on the LDAP source.
@ -97,9 +83,9 @@ Sources created prior to the 2024.2 release have this setting turned on by defau
Be aware of the following security considerations when turning on this functionality:
- Updating the LDAP password does not invalidate the password stored in authentik; however for LDAP Servers like FreeIPA and Active Directory, authentik will lock its internal password during the next LDAP sync. For other LDAP servers, the old passwords will still be valid indefinitely.
- Updating the LDAP password does not invalid the password stored in authentik, however for LDAP Servers like FreeIPA and Active Directory, authentik will lock its internal password during the next LDAP sync. For other LDAP servers, the old passwords will still be valid indefinitely.
- Logging in via LDAP credentials overwrites the password stored in authentik if users have different passwords in LDAP and authentik.
- Custom security measures that are used to secure the password in LDAP may differ from the ones used in authentik. Depending on threat model and security requirements this could lead to unknowingly being non-compliant.
- Custom security measures used to secure the password in LDAP may differ from the ones used in authentik. Depending on threat model and security requirements this could lead to unknowingly being non-compliant.
## Troubleshooting

View File

@ -6,7 +6,7 @@ title: Upgrade PostgreSQL on Docker Compose
Dump your existing database with `docker compose exec postgresql pg_dump -U authentik -d authentik -cC > upgrade_backup_12.sql`.
Before continuing, ensure the SQL dump file (`upgrade_backup_12.sql`) includes all your database content.
Before continuing, ensure the SQL dump file `(upgrade_backup_12.sql`) includes all your database content.
### Stop your authentik stack

View File

@ -12,36 +12,37 @@ title: Amazon Web Services
## Select your method
There are two ways to perform the integration: the classic IAM SAML way, or the 'newer' IAM Identity Center way. This all depends on your preference and needs.
There are two ways to perform the integration. The classic IAM SAML way, or the 'newer' IAM Identity Center way.
This all depends on your preference and needs.
## Method 1: Classic IAM
# Method 1: Classic IAM
### Preparation
## Preparation
Create an application in authentik and note the slug, as this will be used later. Create a SAML provider with the following parameters:
- **ACS URL**: `https://signin.aws.amazon.com/saml`
- **Issuer**: `authentik`
- **Binding**: `Post`
- **Audience**: `urn:amazon:webservices`
- ACS URL: `https://signin.aws.amazon.com/saml`
- Issuer: `authentik`
- Binding: `Post`
- Audience: `urn:amazon:webservices`
You can use a custom signing certificate and adjust durations as needed.
You can of course use a custom signing certificate, and adjust durations.
### AWS
## AWS
Create a role with the permissions you desire, and note the ARN.
After configuring the Property Mappings, add them to the SAML Provider in AWS.
After you've created the Property Mappings below, add them to the Provider.
Create an application, assign policies, and assign this provider.
Export the metadata from authentik and create a new Identity Provider [here](https://console.aws.amazon.com/iam/home#/providers).
Export the metadata from authentik, and create an Identity Provider [here](https://console.aws.amazon.com/iam/home#/providers).
#### Role Mapping
The Role mapping specifies the AWS ARN(s) of the identity provider, and the role the user should assume ([see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_saml_assertions.html#saml_role-attribute)).
This Mapping needs to have the SAML Name field set to `https://aws.amazon.com/SAML/Attributes/Role`.
This Mapping needs to have the SAML Name field set to "https://aws.amazon.com/SAML/Attributes/Role"
As expression, you can return a static ARN like so
@ -70,7 +71,7 @@ return [
The RoleSessionMapping specifies what identifier will be shown at the top of the Management Console ([see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_saml_assertions.html#saml_role-session-attribute)).
This mapping needs to have the SAML Name field set to `https://aws.amazon.com/SAML/Attributes/RoleSessionName`.
This mapping needs to have the SAML Name field set to "https://aws.amazon.com/SAML/Attributes/RoleSessionName".
To use the user's username, use this snippet
@ -78,69 +79,70 @@ To use the user's username, use this snippet
return user.username
```
## Method 2: IAM Identity Center
# Method 2: IAM Identity Center
### Preparation
## Preparation
- A certificate to sign SAML assertions is required. You can use authentik's default certificate, or provide/generate one yourself.
- You may pre-create an AWS application.
### How to integrate with AWS
## How to integrate with AWS
In AWS:
- In AWS, navigate to: **IAM Identity Center -> Settings -> Identity Source (tab)**
- On the right side, click **Actions** -> **Change identity source**
- Select **External Identity Provider**
- Under **Service Provider metadata** download the metadata file.
- In AWS navigate to: _IAM Identity Center_ -> _Settings_ -> _Identity Source (tab)_
- On the right side click _Actions_ -> _Change identity source_
- Select _External Identity Provider_
- Under _Service Provider metadata_ download the metadata file.
Now go to your authentik instance, and perform the following steps.
- Under **Providers**, create a new **SAML Provider from metadata**. Give it a name, and upload the metadata file AWS gave you.
- Click **Next**. Give it a name, and close the file.
- Under _Providers_ create a new _SAML Provider from metadata_. Give it a name, and upload the metadata file AWS gave you.
- Click _Next_. Give it a name, and close the file.
- If you haven't done so yet, create an application for AWS and connect the provider to it.
- Navigate to the provider you've just created, and then select **Edit**
- Copy the **Issuer URL** to the **Audience** field.
- Under **Advanced Protocol Settings** set a **Signing Certificate**
- Navigate to the provider you've just created, and then select _Edit_
- Copy the _Issuer URL_ to the _Audience_ field.
- Under _Advanced Protocol Settings_ set a _Signing Certificate_
- Save and Close.
- Under **Related Objects**, download the **Metadata file** and the **Signing Certificate**
- Under _Related Objects_ download the _Metadata file_, and the _Signing Certificate_
Now go back to your AWS instance
- Under **Identity provider metadata**, upload both the **Metadata** file and **Signing Certificate** that authentik gave you.
- Click **Next**.
- In your settings pane, under the tab **Identity Source**, click **Actions** -> **Manage Authentication**.
- Note the AWS access portal sign-in URL (especially if you have customized it).
- Under _Identity provider metadata_ upload both the the _Metadata_ file and _Signing Certificate_ that authentik gave you.
- Click _Next_.
- In your settings pane, under the tab _Identity Source_, click _Actions_ -> _Manage Authentication_.
- Take note of the _AWS access portal sign-in URL_ (this is especially important if you changed it from the default).
Now go back to your authentik instance.
- Navigate to the Application that you created for AWS and click **Edit**.
- Under **UI Settings** make sure the **Start URL** matches the **AWS access portal sign-in URL**.
- Navigate to the Application that you created for AWS and click _Edit_.
- Under _UI Settings_ make sure the _Start URL_ matches the _AWS access portal sign-in URL_
:::::info
## Caveats and Troubleshooting
- Ensure users already exist in AWS for authentication through authentik. AWS will throw an error if the user is unrecognized.
- In case you're stuck, you can see the SSO logs in Amazon CloudTrail -> Event History. Look for `ExtenalIdPDirectoryLogin`.
:::::
- Users need to already exist in AWS in order to use them through authentik. AWS will throw an error if it doesn't recognise the user.
- In case you're stuck, you can see the SSO logs in Amazon CloudTrail -> Event History. Look for `ExtenalIdPDirectoryLogin`
Note:
## Optional: Automated provisioning with SCIM
Some people may opt to use the automatic provisioning feature called SCIM (System for Cross-domain Identity Management).
Some people may opt TO USE the automatic provisioning feature called SCIM (System for Cross-domain Identity Management).
SCIM allows you to synchronize (part of) your directory to AWS's IAM, saving you the hassle of having to create users by hand.
To do so, take the following steps in your AWS Identity Center:
In order to do so, take the following steps in your AWS Identity Center:
- In your **Settings** pane, locate the **Automatic Provisioning** information box. Click **Enable**.
- AWS provides an SCIM Endpoint and an Access Token. Note these values.
- In your _Settings_ pane, locate the _Automatic Provisioning_ information box. Click _Enable_.
- AWS will give you an _SCIM Endpoint_ and a _Access Token_. Take note of these values.
Go back to your authentik instance
- Navigate to **Providers** -> **Create**
- Select **SCIM Provider**
- Give it a name, under **URL** enter the **SCIM Endpoint**, and then under **Token** enter the **Access Token** AWS provided you with.
- Optionally, change the user filtering settings to your liking. Click **Finish**
- Navigate to _Providers_ -> _Create_
- Select _SCIM Provider_
- Give it a name, under _URL_ enter the _SCIM Endpoint_, and then under _Token_ enter the _Access Token_ AWS provided you with.
- Optionally, change the user filtering settings to your liking. Click _Finish_
- Go to **Customization -> Property Mappings**
- Click **Create -> SCIM Mapping**
- Go to _Customization -> Property Mappings_
- Click _Create -> SCIM Mapping_
- Make sure to give the mapping a name that's lexically lower than `authentik default`, for example `AWS SCIM User mapping`
- As the expression, enter:
@ -152,12 +154,12 @@ return {
}
```
- Click **Save**. Navigate back to your SCIM provider, click **Edit**
- Under **User Property Mappings** select the default mapping and the mapping that you just created.
- Click **Update**
- Click _Save_. Navigate back to your SCIM provider, click _Edit_
- Under _User Property Mappings_ select the default mapping and the mapping that you just created.
- Click _Update_
- Navigate to your application, click **Edit**.
- Under **Backchannel providers** add the SCIM provider that you created.
- Click **Update**
- Navigate to your application, click _Edit_.
- Under _Backchannel providers_ add the SCIM provider that you created.
- Click _Update_
The SCIM provider syncs automatically whenever you create/update/remove users, groups, or group membership. You can manually sync by going to your SCIM provider and clicking **Run sync again**. After the SCIM provider has synced, you should see the users and groups in your AWS IAM center.
The SCIM provider syncs automatically whenever you create/update/remove users, groups, or group membership. You can manually sync by going to your SCIM provider and clicking _Run sync again_. After the SCIM provider has synced, you should see the users and groups in your AWS IAM center.

View File

@ -35,7 +35,6 @@
"@docusaurus/tsconfig": "^3.4.0",
"@docusaurus/types": "^3.3.2",
"@types/react": "^18.3.3",
"cross-env": "^7.0.3",
"lockfile-lint": "^4.14.0",
"prettier": "3.3.2",
"typescript": "~5.5.3"
@ -5273,24 +5272,6 @@
"sha.js": "^2.4.8"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"license": "MIT",

View File

@ -4,8 +4,8 @@
"private": true,
"license": "MIT",
"scripts": {
"build": "cp ../docker-compose.yml static/docker-compose.yml && cp ../schema.yml static/schema.yaml && docusaurus gen-api-docs all && cross-env NODE_OPTIONS='--max_old_space_size=65536' docusaurus build",
"build-bundled": "cp ../schema.yml static/schema.yaml && docusaurus gen-api-docs all && cross-env NODE_OPTIONS='--max_old_space_size=65536' docusaurus build",
"build": "cp ../docker-compose.yml static/docker-compose.yml && cp ../schema.yml static/schema.yaml && docusaurus gen-api-docs all && docusaurus build",
"build-bundled": "cp ../schema.yml static/schema.yaml && docusaurus gen-api-docs all && docusaurus build",
"deploy": "docusaurus deploy",
"docusaurus": "docusaurus",
"lint:lockfile": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https",
@ -55,7 +55,6 @@
"@docusaurus/tsconfig": "^3.4.0",
"@docusaurus/types": "^3.3.2",
"@types/react": "^18.3.3",
"cross-env": "^7.0.3",
"lockfile-lint": "^4.14.0",
"prettier": "3.3.2",
"typescript": "~5.5.3"

View File

@ -253,7 +253,6 @@ const docsSidebar = {
label: "Executors",
items: [
"flow/executors/if-flow",
"flow/executors/sfe",
"flow/executors/user-settings",
"flow/executors/headless",
],