Compare commits

...

12 Commits

Author SHA1 Message Date
195091ed3b idk
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-27 19:09:33 +01:00
4de3f1f4b8 only create websocket connection for non-frame mode
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
af4f1b3421 revoke access token when user logs out
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
77b816ad51 fix interface and non frame redirect
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
b28dd485a0 don't show logo when using frame mode
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
4701389745 re-fix style
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
0d0097e956 idk
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
b42eb0706d set schema
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
3afe386e18 also pass raw email token for custom email templates
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
34dd9c0b63 add CSP middleware that allows frame embeds based on brand
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
b2f2fd241d prepare flow frame
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:18 +01:00
828f477548 add default app and restrict
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-26 15:21:16 +01:00
29 changed files with 443 additions and 79 deletions

View File

@ -46,6 +46,7 @@ class BrandSerializer(ModelSerializer):
fields = [
"brand_uuid",
"domain",
"origin",
"default",
"branding_title",
"branding_logo",
@ -56,6 +57,7 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"default_application",
"web_certificate",
"attributes",
]

View File

@ -1,12 +1,17 @@
"""Inject brand into current request"""
from collections.abc import Callable
from typing import TYPE_CHECKING
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import activate
from authentik.brands.utils import get_brand_for_request
from authentik.lib.config import CONFIG
if TYPE_CHECKING:
from authentik.brands.models import Brand
class BrandMiddleware:
@ -25,3 +30,41 @@ class BrandMiddleware:
if locale != "":
activate(locale)
return self.get_response(request)
class BrandHeaderMiddleware:
"""Add headers from currently active brand"""
get_response: Callable[[HttpRequest], HttpResponse]
default_csp_elements: dict[str, list[str]] = {}
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
self.default_csp_elements = {
"style-src": ["'self'", "'unsafe-inline'"], # Required due to Lit/ShadowDOM
"script-src": ["'self'", "'unsafe-inline'"], # Required for generated scripts
"img-src": ["https:", "http:", "data:"],
"default-src": ["'self'"],
"object-src": ["'none'"],
"connect-src": ["'self'"],
}
if CONFIG.get_bool("error_reporting.enabled"):
self.default_csp_elements["connect-src"].append(
# Required for sentry (TODO: Dynamic)
"https://authentik.error-reporting.a7k.io"
)
if CONFIG.get_bool("debug"):
# Also allow spotlight sidecar connection
self.default_csp_elements["connect-src"].append("http://localhost:8969")
def get_csp(self, request: HttpRequest) -> str:
brand: "Brand" = request.brand
elements = self.default_csp_elements.copy()
if brand.origin != "":
elements["frame-ancestors"] = [brand.origin]
return ";".join(f"{attr} {" ".join(value)}" for attr, value in elements.items())
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
response.headers["Content-Security-Policy"] = self.get_csp(request)
return response

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.3 on 2024-03-21 15:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0005_tenantuuid_to_branduuid"),
("authentik_core", "0033_alter_user_options"),
]
operations = [
migrations.AddField(
model_name="brand",
name="default_application",
field=models.ForeignKey(
default=None,
help_text="When set, external users will be redirected to this application after authenticating.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.application",
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.0.3 on 2024-03-26 14:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0006_brand_default_application"),
]
operations = [
migrations.AddField(
model_name="brand",
name="origin",
field=models.TextField(
blank=True,
help_text="Origin domain that activates this brand. Can be left empty to not allow any origins.",
),
),
]

View File

@ -23,6 +23,12 @@ class Brand(SerializerModel):
"Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
)
)
origin = models.TextField(
help_text=_(
"Origin domain that activates this brand. Can be left empty to not allow any origins."
),
blank=True,
)
default = models.BooleanField(
default=False,
)
@ -51,6 +57,16 @@ class Brand(SerializerModel):
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
default_application = models.ForeignKey(
"authentik_core.Application",
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_(
"When set, external users will be redirected to this application after authenticating."
),
)
web_certificate = models.ForeignKey(
CertificateKeyPair,
null=True,

View File

@ -1,11 +1,15 @@
"""Brand utilities"""
from typing import Any
from urllib.parse import urlparse
from django.db.models import F, Q
from django.db.models import Value as V
from django.http import HttpResponse
from django.http.request import HttpRequest
from django.utils.cache import patch_vary_headers
from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger
from authentik import get_full_version
from authentik.brands.models import Brand
@ -13,13 +17,17 @@ from authentik.tenants.models import Tenant
_q_default = Q(default=True)
DEFAULT_BRAND = Brand(domain="fallback")
LOGGER = get_logger()
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""
query = Q(host_domain__iendswith=F("domain"))
if "Origin" in request.headers:
query &= Q(Q(origin=request.headers.get("Origin", "")) | Q(origin=""))
db_brands = (
Brand.objects.annotate(host_domain=V(request.get_host()))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.filter(Q(query) | _q_default)
.order_by("default")
)
brands = list(db_brands.all())
@ -42,3 +50,46 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
"sentry_trace": trace,
"version": get_full_version(),
}
def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: str):
"""Add headers to permit CORS requests from allowed_origins, with or without credentials,
with any headers."""
origin = request.META.get("HTTP_ORIGIN")
if not origin:
return response
# OPTIONS requests don't have an authorization header -> hence
# we can't extract the provider this request is for
# so for options requests we allow the calling origin without checking
allowed = request.method == "OPTIONS"
received_origin = urlparse(origin)
for allowed_origin in allowed_origins:
url = urlparse(allowed_origin)
if (
received_origin.scheme == url.scheme
and received_origin.hostname == url.hostname
and received_origin.port == url.port
):
allowed = True
if not allowed:
LOGGER.warning(
"CORS: Origin is not an allowed origin",
requested=received_origin,
allowed=allowed_origins,
)
return response
# From the CORS spec: The string "*" cannot be used for a resource that supports credentials.
response["Access-Control-Allow-Origin"] = origin
patch_vary_headers(response, ["Origin"])
response["Access-Control-Allow-Credentials"] = "true"
if request.method == "OPTIONS":
if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META:
response["Access-Control-Allow-Headers"] = request.META[
"HTTP_ACCESS_CONTROL_REQUEST_HEADERS"
]
response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return response

View File

@ -6,7 +6,6 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
@ -20,7 +19,12 @@ from authentik.core.api.transactional_applications import TransactionalApplicati
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 FlowInterfaceView, InterfaceView
from authentik.core.views.interface import (
BrandDefaultRedirectView,
FlowInterfaceView,
InterfaceView,
RootRedirectView,
)
from authentik.core.views.session import EndSessionView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
@ -29,13 +33,11 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
path(
"",
login_required(
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
),
login_required(RootRedirectView.as_view()),
name="root-redirect",
),
path(
# We have to use this format since everything else uses applications/o or applications/saml
# We have to use this format since everything else uses application/o or application/saml
"application/launch/<slug:application_slug>/",
apps.RedirectToAppLaunch.as_view(),
name="application-launch",
@ -43,12 +45,12 @@ urlpatterns = [
# Interfaces
path(
"if/admin/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")),
name="if-user",
),
path(

View File

@ -3,15 +3,43 @@
from json import dumps
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from django.http import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext as _
from django.views.generic.base import RedirectView, TemplateView
from rest_framework.request import Request
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.brands.models import Brand
from authentik.core.models import UserTypes
from authentik.flows.models import Flow
from authentik.policies.denied import AccessDeniedResponse
class RootRedirectView(RedirectView):
"""Root redirect view, redirect to brand's default application if set"""
pattern_name = "authentik_core:if-user"
query_string = True
def redirect_to_app(self, request: HttpRequest):
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
brand: Brand = request.brand
if brand.default_application:
return redirect(
"authentik_core:application-launch",
application_slug=brand.default_application.slug,
)
return None
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if redirect_response := RootRedirectView().redirect_to_app(request):
return redirect_response
return super().dispatch(request, *args, **kwargs)
class InterfaceView(TemplateView):
@ -27,6 +55,22 @@ class InterfaceView(TemplateView):
return super().get_context_data(**kwargs)
class BrandDefaultRedirectView(InterfaceView):
"""By default redirect to default app"""
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
brand: Brand = request.brand
if brand.default_application:
return redirect(
"authentik_core:application-launch",
application_slug=brand.default_application.slug,
)
response = AccessDeniedResponse(self.request)
response.error_message = _("Interface can only be accessed by internal users.")
return super().dispatch(request, *args, **kwargs)
class FlowInterfaceView(InterfaceView):
"""Flow interface"""

View File

@ -24,6 +24,7 @@ from sentry_sdk.hub import Hub
from structlog.stdlib import BoundLogger, get_logger
from authentik.brands.models import Brand
from authentik.brands.utils import cors_allow
from authentik.core.models import Application
from authentik.events.models import Event, EventAction, cleanse_dict
from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME
@ -155,6 +156,14 @@ class FlowExecutorView(APIView):
return plan
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
response = self.dispatch_wrapper(request, flow_slug)
origins = []
if request.brand.origin != "":
origins.append(request.brand.origin)
cors_allow(request, response, *origins)
return response
def dispatch_wrapper(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
with Hub.current.start_span(
op="authentik.flow.executor.dispatch", description=self.flow.slug
) as span:

View File

@ -1,9 +1,9 @@
"""authentik oauth provider app config"""
from django.apps import AppConfig
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikProviderOAuth2Config(AppConfig):
class AuthentikProviderOAuth2Config(ManagedAppConfig):
"""authentik oauth provider app config"""
name = "authentik.providers.oauth2"
@ -13,3 +13,4 @@ class AuthentikProviderOAuth2Config(AppConfig):
"authentik.providers.oauth2.urls_root": "",
"authentik.providers.oauth2.urls": "application/o/",
}
default = True

View File

@ -0,0 +1,15 @@
from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import User
from authentik.providers.oauth2.models import AccessToken
@receiver(user_logged_out)
def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
"""Revoke access tokens upon user logout"""
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()

View File

@ -4,11 +4,9 @@ import re
from base64 import b64decode
from binascii import Error
from typing import Any
from urllib.parse import urlparse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http.response import HttpResponseRedirect
from django.utils.cache import patch_vary_headers
from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
@ -30,49 +28,6 @@ class TokenResponse(JsonResponse):
self["Pragma"] = "no-cache"
def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: str):
"""Add headers to permit CORS requests from allowed_origins, with or without credentials,
with any headers."""
origin = request.META.get("HTTP_ORIGIN")
if not origin:
return response
# OPTIONS requests don't have an authorization header -> hence
# we can't extract the provider this request is for
# so for options requests we allow the calling origin without checking
allowed = request.method == "OPTIONS"
received_origin = urlparse(origin)
for allowed_origin in allowed_origins:
url = urlparse(allowed_origin)
if (
received_origin.scheme == url.scheme
and received_origin.hostname == url.hostname
and received_origin.port == url.port
):
allowed = True
if not allowed:
LOGGER.warning(
"CORS: Origin is not an allowed origin",
requested=received_origin,
allowed=allowed_origins,
)
return response
# From the CORS spec: The string "*" cannot be used for a resource that supports credentials.
response["Access-Control-Allow-Origin"] = origin
patch_vary_headers(response, ["Origin"])
response["Access-Control-Allow-Credentials"] = "true"
if request.method == "OPTIONS":
if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META:
response["Access-Control-Allow-Headers"] = request.META[
"HTTP_ACCESS_CONTROL_REQUEST_HEADERS"
]
response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return response
def extract_access_token(request: HttpRequest) -> str | None:
"""
Get the access token using Authorization Request Header Field method.

View File

@ -8,6 +8,7 @@ from django.views import View
from guardian.shortcuts import get_anonymous_user
from structlog.stdlib import get_logger
from authentik.brands.utils import cors_allow
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.models import Application
from authentik.providers.oauth2.constants import (
@ -28,7 +29,6 @@ from authentik.providers.oauth2.models import (
ResponseTypes,
ScopeMapping,
)
from authentik.providers.oauth2.utils import cors_allow
LOGGER = get_logger()

View File

@ -20,6 +20,7 @@ from jwt import PyJWK, PyJWT, PyJWTError, decode
from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger
from authentik.brands.utils import cors_allow
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import (
USER_ATTRIBUTE_EXPIRES,
@ -59,7 +60,7 @@ from authentik.providers.oauth2.models import (
OAuth2Provider,
RefreshToken,
)
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS

View File

@ -11,6 +11,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.brands.utils import cors_allow
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import PermissionDict
@ -28,7 +29,7 @@ from authentik.providers.oauth2.models import (
RefreshToken,
ScopeMapping,
)
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, protected_resource_view
from authentik.providers.oauth2.utils import TokenResponse, protected_resource_view
LOGGER = get_logger()

View File

@ -241,7 +241,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"authentik.root.middleware.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"authentik.brands.middleware.BrandHeaderMiddleware",
"authentik.core.middleware.ImpersonateMiddleware",
"django_prometheus.middleware.PrometheusAfterMiddleware",
]

View File

@ -118,6 +118,7 @@ class EmailStageView(ChallengeStageView):
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
"user": pending_user,
"expires": token.expires,
"token": token.key,
},
)
send_mails(current_stage, message)

View File

@ -7609,6 +7609,11 @@
"title": "Domain",
"description": "Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
},
"origin": {
"type": "string",
"title": "Origin",
"description": "Origin domain that activates this brand. Can be left empty to not allow any origins."
},
"default": {
"type": "boolean",
"title": "Default"
@ -7652,6 +7657,11 @@
"type": "integer",
"title": "Flow device code"
},
"default_application": {
"type": "integer",
"title": "Default application",
"description": "When set, external users will be redirected to this application after authenticating."
},
"web_certificate": {
"type": "integer",
"title": "Web certificate",

View File

@ -31334,6 +31334,10 @@ components:
type: string
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
for `aa.b` and `ba.b`
origin:
type: string
description: Origin domain that activates this brand. Can be left empty
to not allow any origins.
default:
type: boolean
branding_title:
@ -31366,6 +31370,12 @@ components:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
nullable: true
description: When set, external users will be redirected to this application
after authenticating.
web_certificate:
type: string
format: uuid
@ -31384,6 +31394,10 @@ components:
minLength: 1
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
for `aa.b` and `ba.b`
origin:
type: string
description: Origin domain that activates this brand. Can be left empty
to not allow any origins.
default:
type: boolean
branding_title:
@ -31419,6 +31433,12 @@ components:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
nullable: true
description: When set, external users will be redirected to this application
after authenticating.
web_certificate:
type: string
format: uuid
@ -38518,6 +38538,10 @@ components:
minLength: 1
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
for `aa.b` and `ba.b`
origin:
type: string
description: Origin domain that activates this brand. Can be left empty
to not allow any origins.
default:
type: boolean
branding_title:
@ -38553,6 +38577,12 @@ components:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
nullable: true
description: When set, external users will be redirected to this application
after authenticating.
web_certificate:
type: string
format: uuid

View File

@ -15,7 +15,13 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { Brand, CoreApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
import {
Application,
Brand,
CoreApi,
CoreApplicationsListRequest,
FlowsInstancesListDesignationEnum,
} from "@goauthentik/api";
@customElement("ak-brand-form")
export class BrandForm extends ModelForm<Brand, string> {
@ -137,6 +143,46 @@ export class BrandForm extends ModelForm<Brand, string> {
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("External user settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Default application")}
name="defaultApplication"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Application[]> => {
const args: CoreApplicationsListRequest = {
ordering: "name",
superuserFullList: true,
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(
DEFAULT_CONFIG,
).coreApplicationsList(args);
return users.results;
}}
.renderElement=${(item: Application): string => {
return item.name;
}}
.renderDescription=${(item: Application): TemplateResult => {
return html`${item.slug}`;
}}
.value=${(item: Application | undefined): string | undefined => {
return item?.pk;
}}
.selected=${(item: Application): boolean => {
return item.pk === this.instance?.defaultApplication;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Default flows")} </span>
<div slot="body" class="pf-c-form">

View File

@ -13,13 +13,14 @@ import { TablePage } from "@goauthentik/elements/table/TablePage";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Brand, CoreApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
import { WithBrandConfig } from "@goauthentik/authentik/elements/Interface/brandProvider";
@customElement("ak-brand-list")
export class BrandListPage extends TablePage<Brand> {
export class BrandListPage extends WithBrandConfig(TablePage<Brand>) {
searchEnabled(): boolean {
return true;
}
@ -84,7 +85,9 @@ export class BrandListPage extends TablePage<Brand> {
row(item: Brand): TemplateResult[] {
return [
html`${item.domain}`,
html`${item.domain}${this.brand.matchedDomain === item.domain ? html`
&nbsp;<ak-status-label ?good=${false} type="info" bad-label=${msg("Active")}></ak-status-label>
` : nothing}`,
html`${item.brandingTitle}`,
html`<ak-status-label ?good=${item._default}></ak-status-label>`,
html`<ak-forms-modal>

View File

@ -29,6 +29,8 @@ class PreviewStageHost implements StageHost {
flowSlug = undefined;
loading = false;
brand = undefined;
frameMode = false;
async submit(payload: unknown): Promise<boolean> {
this.promptForm.previewResult = payload;
return false;

View File

@ -61,16 +61,18 @@ export function brand(): Promise<CurrentBrand> {
return globalBrandPromise;
}
export function getMetaContent(key: string): string {
export function getMetaContent(key: string): string | undefined {
const metaEl = document.querySelector<HTMLMetaElement>(`meta[name=${key}]`);
if (!metaEl) return "";
return metaEl.content;
return metaEl?.content;
}
export const DEFAULT_CONFIG = new Configuration({
basePath: (process.env.AK_API_BASE_PATH || window.location.origin) + "/api/v3",
basePath:
(process.env.AK_API_BASE_PATH ||
getMetaContent("authentik-api") ||
window.location.origin) + "/api/v3",
headers: {
"sentry-trace": getMetaContent("sentry-trace"),
"sentry-trace": getMetaContent("sentry-trace") || "",
},
middleware: [
new CSRFMiddleware(),

View File

@ -1,3 +1,4 @@
import { ensureCSSStyleSheet } from "@goauthentik/authentik/elements/utils/ensureCSSStyleSheet";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import {
EVENT_FLOW_ADVANCE,
@ -76,7 +77,8 @@ export class FlowExecutor extends Interface implements StageHost {
@state()
flowInfo?: ContextualFlowInfo;
ws: WebsocketClient;
@state()
frameMode = window !== window.top;
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage].concat(css`
@ -140,6 +142,25 @@ export class FlowExecutor extends Interface implements StageHost {
:host([theme="dark"]) .pf-c-login.sidebar_right .pf-c-list {
color: var(--ak-dark-foreground);
}
/* frame design */
.pf-c-login.frame {
padding: 0;
min-height: initial !important;
}
.pf-c-login.frame .pf-c-login__main {
margin-bottom: 0;
height: 100%;
}
.pf-c-login.frame .ak-login-container {
height: 100vh;
width: 100vw;
}
.pf-c-login.frame .pf-c-login__footer {
display: none;
}
.pf-c-login.frame .pf-c-login__footer .pf-c-list {
padding: 0;
}
.pf-c-brand {
padding-top: calc(
var(--pf-c-login__main-footer-links--PaddingTop) +
@ -161,7 +182,21 @@ export class FlowExecutor extends Interface implements StageHost {
constructor() {
super();
this.ws = new WebsocketClient();
if (this.frameMode) {
// PatternFly sets html and body to 100% height, which we don't
// want in this case since the iframe should only take the space it needs
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
ensureCSSStyleSheet(css`
body,
html {
height: unset !important;
}
`),
];
} else {
new WebsocketClient();
}
if (window.location.search.includes("inspector")) {
this.inspectorOpen = true;
}
@ -437,9 +472,11 @@ export class FlowExecutor extends Interface implements StageHost {
}
renderChallengeWrapper(): TemplateResult {
const logo = html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
</div>`;
const logo = this.frameMode
? nothing
: html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
</div>`;
if (!this.challenge) {
return html`${logo}<ak-empty-state ?loading=${true} header=${msg("Loading")}>
</ak-empty-state>`;
@ -482,7 +519,26 @@ export class FlowExecutor extends Interface implements StageHost {
}
render(): TemplateResult {
return html` <ak-locale-context>
if (this.frameMode) {
return html`<ak-locale-context>
<div class="pf-c-login frame">
<div class="ak-login-container">
<div class="pf-c-login__main">${this.renderChallengeWrapper()}</div>
<footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline">
<li>
<a
href="https://goauthentik.io?utm_source=authentik&amp;utm_medium=flow"
>${msg("Powered by authentik")}</a
>
</li>
</ul>
</footer>
</div>
</div>
</ak-locale-context>`;
}
return html`<ak-locale-context>
<div class="pf-c-background-image"></div>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer ${this.inspectorOpen ? "pf-m-expanded" : "pf-m-collapsed"}">

View File

@ -49,8 +49,27 @@ export class RedirectStage extends BaseStage<RedirectChallenge, FlowChallengeRes
"authentik/stages/redirect: redirecting to url from server",
this.challenge.to,
);
window.location.assign(this.challenge.to);
this.startedRedirect = true;
if (this.host.frameMode && window.top) {
try {
window.top.location.assign(this.challenge.to);
} catch {
window.top.postMessage(
{
source: "goauthentik.io",
context: "flow-executor",
component: this.challenge.component,
to: this.challenge.to,
},
document.location.ancestorOrigins[0],
);
}
if (this.challenge.to.startsWith("http")) {
return;
}
} else {
window.location.assign(this.challenge.to);
}
}
renderLoading(): TemplateResult {

View File

@ -47,6 +47,10 @@ export class AuthenticatorValidateStage
return this.host.brand;
}
get frameMode(): boolean {
return this.host.frameMode;
}
@state()
_selectedDeviceChallenge?: DeviceChallenge;

View File

@ -16,6 +16,7 @@ export interface StageHost {
challenge?: unknown;
flowSlug?: string;
loading: boolean;
frameMode: boolean;
submit(payload: unknown, options?: SubmitOptions): Promise<boolean>;
readonly brand?: CurrentBrand;

View File

@ -22,6 +22,7 @@ import "@goauthentik/elements/sidebar/Sidebar";
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import "@goauthentik/elements/sidebar/SidebarItem";
import { ROUTES } from "@goauthentik/user/Routes";
import "@goauthentik/user/user-settings/details/UserSettingsFlowExecutor";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { match } from "ts-pattern";

View File

@ -53,6 +53,8 @@ export class UserSettingsFlowExecutor
@property({ type: Boolean })
loading = false;
frameMode = false;
static get styles(): CSSResult[] {
return [PFBase, PFCard, PFPage, PFButton, PFContent];
}
@ -87,7 +89,7 @@ export class UserSettingsFlowExecutor
}
firstUpdated(): void {
this.flowSlug = this.brand?.flowUserSettings;
this.flowSlug = this.flowSlug || this.brand?.flowUserSettings;
if (!this.flowSlug) {
return;
}