diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py index c023a5d17114..a63e08529103 100644 --- a/authentik/providers/saml/api/providers.py +++ b/authentik/providers/saml/api/providers.py @@ -313,7 +313,7 @@ def metadata(self, request: Request, pk: int) -> Response: "multipart/form-data": SAMLProviderImportSerializer, }, responses={ - 204: OpenApiResponse(description="Successfully imported provider"), + 201: SAMLProviderSerializer, 400: OpenApiResponse(description="Bad request"), }, ) @@ -330,17 +330,18 @@ def import_metadata(self, request: Request, body: SAMLProviderImportSerializer) file.seek(0) try: metadata = ServiceProviderMetadataParser().parse(file.read().decode()) - metadata.to_provider( + provider = metadata.to_provider( body.validated_data["name"], body.validated_data["authorization_flow"], body.validated_data["invalidation_flow"], ) + # Return the created provider for use in workflows like the application wizard + return Response(SAMLProviderSerializer(provider).data, status=201) except ValueError as exc: # pragma: no cover LOGGER.warning(str(exc)) raise ValidationError( _("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})), ) from None - return Response(status=204) @permission_required( "authentik_providers_saml.view_samlprovider", diff --git a/authentik/providers/saml/tests/test_api.py b/authentik/providers/saml/tests/test_api.py index e437cd04b7ba..4fd89c5489e2 100644 --- a/authentik/providers/saml/tests/test_api.py +++ b/authentik/providers/saml/tests/test_api.py @@ -145,6 +145,9 @@ def test_metadata_invalid(self): def test_import_success(self): """Test metadata import (success case)""" + name = generate_id() + authorization_flow = create_test_flow(FlowDesignation.AUTHORIZATION) + invalidation_flow = create_test_flow(FlowDesignation.INVALIDATION) with TemporaryFile() as metadata: metadata.write(load_fixture("fixtures/simple.xml").encode()) metadata.seek(0) @@ -152,14 +155,18 @@ def test_import_success(self): reverse("authentik_api:samlprovider-import-metadata"), { "file": metadata, - "name": generate_id(), - "authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk, - "invalidation_flow": create_test_flow(FlowDesignation.INVALIDATION).pk, + "name": name, + "authorization_flow": authorization_flow.pk, + "invalidation_flow": invalidation_flow.pk, }, format="multipart", ) - self.assertEqual(204, response.status_code) - # We don't test the actual object being created here, that has its own tests + self.assertEqual(201, response.status_code) + body = response.json() + self.assertIn("pk", body) + self.assertEqual(body["name"], name) + self.assertEqual(body["authorization_flow"], str(authorization_flow.pk)) + self.assertEqual(body["invalidation_flow"], str(invalidation_flow.pk)) def test_import_failed(self): """Test metadata import (invalid xml)""" diff --git a/schema.yml b/schema.yml index 9d20dc00d570..68dd891e870b 100644 --- a/schema.yml +++ b/schema.yml @@ -18431,8 +18431,12 @@ paths: security: - authentik: [] responses: - '204': - description: Successfully imported provider + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/SAMLProvider' + description: '' '400': description: Bad request '403': diff --git a/web/src/admin/applications/wizard/steps/ProviderChoices.ts b/web/src/admin/applications/wizard/steps/ProviderChoices.ts index e11a0a3f1137..ad089903308b 100644 --- a/web/src/admin/applications/wizard/steps/ProviderChoices.ts +++ b/web/src/admin/applications/wizard/steps/ProviderChoices.ts @@ -17,33 +17,38 @@ export const providerTypeRenderers: Record< oauth2provider: { render: () => html``, - order: 90, + order: 95, }, - ldapprovider: { + samlprovider: { render: () => - html``, - order: 70, + html``, + order: 90, }, - proxyprovider: { + samlproviderimportmodel: { render: () => - html``, - order: 75, + html``, + order: 85, }, racprovider: { render: () => html``, order: 80, }, - samlprovider: { + proxyprovider: { render: () => - html``, - order: 80, + html``, + order: 75, }, radiusprovider: { render: () => html``, order: 70, }, + ldapprovider: { + render: () => + html``, + order: 65, + }, scimprovider: { render: () => html``, diff --git a/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts b/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts index d0599bb1565f..91a2591df2d4 100644 --- a/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts +++ b/web/src/admin/applications/wizard/steps/SubmitStepOverviewRenderers.ts @@ -8,6 +8,7 @@ import { MatchingModeEnum, OAuth2Provider, ProviderModelEnum, + ProvidersSamlImportMetadataCreateRequest, ProxyMode, ProxyProvider, RACProvider, @@ -37,6 +38,15 @@ function renderSAMLOverview(rawProvider: OneOfProvider) { ]); } +function renderSAMLImportOverview(rawProvider: OneOfProvider) { + const provider = rawProvider as ProvidersSamlImportMetadataCreateRequest; + + return renderSummary("SAML", provider.name, [ + [msg("Authorization flow"), provider.authorizationFlow ?? "-"], + [msg("Invalidation flow"), provider.invalidationFlow ?? "-"], + ]); +} + function renderSCIMOverview(rawProvider: OneOfProvider) { const provider = rawProvider as SCIMProvider; return renderSummary("SCIM", provider.name, [[msg("URL"), provider.url]]); @@ -149,6 +159,7 @@ const providerName = (p: ProviderModelEnum): string => p.toString().split(".")[1 export const providerRenderers = new Map([ [providerName(ProviderModelEnum.AuthentikProvidersSamlSamlprovider), renderSAMLOverview], + ["samlproviderimportmodel", renderSAMLImportOverview], [providerName(ProviderModelEnum.AuthentikProvidersScimScimprovider), renderSCIMOverview], [providerName(ProviderModelEnum.AuthentikProvidersRadiusRadiusprovider), renderRadiusOverview], [providerName(ProviderModelEnum.AuthentikProvidersRacRacprovider), renderRACOverview], diff --git a/web/src/admin/applications/wizard/steps/ak-application-wizard-provider-step.ts b/web/src/admin/applications/wizard/steps/ak-application-wizard-provider-step.ts index 54de4495ddfb..dc84178fdbd3 100644 --- a/web/src/admin/applications/wizard/steps/ak-application-wizard-provider-step.ts +++ b/web/src/admin/applications/wizard/steps/ak-application-wizard-provider-step.ts @@ -4,6 +4,7 @@ import "./providers/ak-application-wizard-provider-for-proxy.js"; import "./providers/ak-application-wizard-provider-for-rac.js"; import "./providers/ak-application-wizard-provider-for-radius.js"; import "./providers/ak-application-wizard-provider-for-saml.js"; +import "./providers/ak-application-wizard-provider-for-saml-metadata.js"; import "./providers/ak-application-wizard-provider-for-scim.js"; import { ApplicationWizardStep } from "../ApplicationWizardStep.js"; @@ -24,6 +25,7 @@ const providerToTag = new Map([ ["racprovider", "ak-application-wizard-provider-for-rac"], ["radiusprovider", "ak-application-wizard-provider-for-radius"], ["samlprovider", "ak-application-wizard-provider-for-saml"], + ["samlproviderimportmodel", "ak-application-wizard-provider-for-saml-metadata"], ["scimprovider", "ak-application-wizard-provider-for-scim"], ]); diff --git a/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts b/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts index a830c2e8cbf7..b7548db7f803 100644 --- a/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts +++ b/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts @@ -19,10 +19,14 @@ import { CoreApi, instanceOfValidationError, type ModelRequest, + PoliciesApi, type PolicyBinding, ProviderModelEnum, + ProvidersApi, + type ProvidersSamlImportMetadataCreateRequest, ProxyMode, type ProxyProviderRequest, + type SAMLProvider, type TransactionApplicationRequest, type TransactionApplicationResponse, type TransactionPolicyBindingRequest, @@ -100,6 +104,56 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio @state() state: SubmitStates = "reviewing"; + async sendSAMLMetadataImport() { + const providerData = this.wizard.provider as ProvidersSamlImportMetadataCreateRequest; + const providersApi = new ProvidersApi(DEFAULT_CONFIG); + const coreApi = new CoreApi(DEFAULT_CONFIG); + const policiesApi = new PoliciesApi(DEFAULT_CONFIG); + + try { + // Step 1: Import SAML metadata to create the provider + const createdProvider = (await providersApi.providersSamlImportMetadataCreate({ + file: providerData.file, + name: providerData.name, + authorizationFlow: providerData.authorizationFlow || "", + invalidationFlow: providerData.invalidationFlow || "", + })) as unknown as SAMLProvider; + + // Step 2: Create the application linked to the provider + const appData = cleanApplication(this.wizard.app); + appData.provider = createdProvider.pk; + + const createdApp = await coreApi.coreApplicationsCreate({ + applicationRequest: appData, + }); + + // Step 3: Create policy bindings + for (const binding of this.wizard.bindings ?? []) { + const bindingData = cleanBinding(binding); + await policiesApi.policiesBindingsCreate({ + policyBindingRequest: { + ...bindingData, + target: createdApp.pk, + }, + }); + } + + this.dispatchCustomEvent(EVENT_REFRESH); + this.state = "submitted"; + } catch (error) { + const parsedError = await parseAPIResponseError(error); + + if (!instanceOfValidationError(parsedError)) { + showAPIErrorMessage(parsedError); + this.state = "reviewing"; + return; + } + + this.handleUpdate({ errors: parsedError }); + this.state = "reviewing"; + } + } + async send() { const app = this.wizard.app; const provider = this.wizard.provider as ModelRequest; @@ -112,6 +166,13 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio throw new Error("Reached the submit state with the provider undefined"); } + this.state = "running"; + + // Special case for SAML metadata import - use a two-step process + if (this.wizard.providerModel === "samlproviderimportmodel") { + return this.sendSAMLMetadataImport(); + } + // Stringly-based API. Not the best, but it works. Just be aware that it is // stringly-based. @@ -133,8 +194,6 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio policyBindings: (this.wizard.bindings ?? []).map(cleanBinding), }; - this.state = "running"; - return new CoreApi(DEFAULT_CONFIG) .coreTransactionalApplicationsUpdate({ transactionApplicationRequest: request, diff --git a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml-metadata.ts b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml-metadata.ts new file mode 100644 index 000000000000..db031b5503d0 --- /dev/null +++ b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml-metadata.ts @@ -0,0 +1,54 @@ +import "#admin/applications/wizard/ak-wizard-title"; + +import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js"; + +import { createFileMap } from "#elements/utils/inputs"; + +import { renderForm } from "#admin/providers/saml/SAMLProviderImportFormForm"; + +import type { ProvidersSamlImportMetadataCreateRequest } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { customElement } from "@lit/reactive-element/decorators.js"; +import { html } from "lit"; + +@customElement("ak-application-wizard-provider-for-saml-metadata") +export class ApplicationWizardProviderSamlMetadataForm extends ApplicationWizardProviderForm { + label = msg("Configure SAML Provider from Metadata"); + + override get formValues() { + const data = super.formValues; + + // Get the file input separately since serializeForm doesn't handle files + const fileMap = createFileMap(this.form?.querySelectorAll("ak-form-element-horizontal")); + const file = fileMap.get("file"); + + if (file) { + data.file = file; + } + + return data; + } + + renderForm() { + return html` + ${this.label} +
+ ${renderForm(this.wizard.provider)} +
+ `; + } + + render() { + if (!(this.wizard.provider && this.wizard.errors)) { + throw new Error("SAML Metadata Provider Step received uninitialized wizard context."); + } + return this.renderForm(); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-application-wizard-provider-for-saml-metadata": ApplicationWizardProviderSamlMetadataForm; + } +} diff --git a/web/src/admin/providers/saml/SAMLProviderImportForm.ts b/web/src/admin/providers/saml/SAMLProviderImportForm.ts index bca552db7e23..1aff41c4cefe 100644 --- a/web/src/admin/providers/saml/SAMLProviderImportForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderImportForm.ts @@ -1,16 +1,13 @@ -import "#admin/common/ak-flow-search/ak-flow-search-no-default"; -import "#elements/forms/HorizontalFormElement"; -import "#elements/forms/SearchSelect/index"; +import { renderForm } from "./SAMLProviderImportFormForm.js"; import { DEFAULT_CONFIG } from "#common/api/config"; import { SentryIgnoredError } from "#common/sentry/index"; import { Form } from "#elements/forms/Form"; -import { FlowsInstancesListDesignationEnum, ProvidersApi, SAMLProvider } from "@goauthentik/api"; +import { ProvidersApi, SAMLProvider } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html, TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; @customElement("ak-provider-saml-import-form") @@ -19,8 +16,8 @@ export class SAMLProviderImportForm extends Form { return msg("Successfully imported provider."); } - async send(data: SAMLProvider): Promise { - const file = this.files().get("metadata"); + async send(data: SAMLProvider): Promise { + const file = this.files().get("file"); if (!file) { throw new SentryIgnoredError("No form data"); } @@ -32,41 +29,8 @@ export class SAMLProviderImportForm extends Form { }); } - renderForm(): TemplateResult { - return html` - - - - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
- - - - `; + renderForm() { + return renderForm(); } } diff --git a/web/src/admin/providers/saml/SAMLProviderImportFormForm.ts b/web/src/admin/providers/saml/SAMLProviderImportFormForm.ts new file mode 100644 index 000000000000..bca3c7a82cf2 --- /dev/null +++ b/web/src/admin/providers/saml/SAMLProviderImportFormForm.ts @@ -0,0 +1,57 @@ +import "#admin/common/ak-flow-search/ak-flow-search-no-default"; +import "#components/ak-text-input"; +import "#elements/forms/HorizontalFormElement"; + +import { + FlowsInstancesListDesignationEnum, + type ProvidersSamlImportMetadataCreateRequest, +} from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; + +export function renderForm(provider: Partial = {}) { + return html` + + + + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+ + + +

+ ${msg("SAML metadata XML file to import provider settings from.")} +

+
+ `; +}