From ce193324502406dab83724535a2c9cc979deb559 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 29 May 2025 03:11:32 +0200 Subject: [PATCH] some progress Signed-off-by: Jens Langhammer --- authentik/flows/stage.py | 1 + authentik/flows/views/interface.py | 6 +- .../openid-conformance/test-config.json | 20 -- .../{manual => }/openid-conformance/README.md | 3 +- tests/openid-conformance/compose.yml | 32 +++ tests/openid-conformance/conformance.py | 249 ++++++++++++++++++ .../openid-conformance/oidc-conformance.yaml | 19 +- tests/openid-conformance/run.py | 114 ++++++++ web/packages/sfe/src/index.ts | 41 ++- 9 files changed, 429 insertions(+), 56 deletions(-) delete mode 100644 tests/manual/openid-conformance/test-config.json rename tests/{manual => }/openid-conformance/README.md (81%) create mode 100644 tests/openid-conformance/compose.yml create mode 100644 tests/openid-conformance/conformance.py rename tests/{manual => }/openid-conformance/oidc-conformance.yaml (82%) create mode 100644 tests/openid-conformance/run.py diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index 6d34fad746..3cbdebddbf 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -108,6 +108,7 @@ class ChallengeStageView(StageView): def post(self, request: Request, *args, **kwargs) -> HttpResponse: """Handle challenge response""" + print(request.data) valid = False try: challenge: ChallengeResponse = self.get_response_instance(data=request.data) diff --git a/authentik/flows/views/interface.py b/authentik/flows/views/interface.py index d8b1e1cebc..082b97601d 100644 --- a/authentik/flows/views/interface.py +++ b/authentik/flows/views/interface.py @@ -38,6 +38,6 @@ class FlowInterfaceView(InterfaceView): 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"] + # if self.compat_needs_sfe() or "sfe" in self.request.GET: + return ["if/flow-sfe.html"] + # return ["if/flow.html"] diff --git a/tests/manual/openid-conformance/test-config.json b/tests/manual/openid-conformance/test-config.json deleted file mode 100644 index 2eed023ffa..0000000000 --- a/tests/manual/openid-conformance/test-config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "alias": "authentik", - "description": "authentik", - "server": { - "discoveryUrl": "http://host.docker.internal:9000/application/o/conformance/.well-known/openid-configuration" - }, - "client": { - "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", - "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867" - }, - "client_secret_post": { - "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", - "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867" - }, - "client2": { - "client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26", - "client_secret": "ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789" - }, - "consent": {} -} diff --git a/tests/manual/openid-conformance/README.md b/tests/openid-conformance/README.md similarity index 81% rename from tests/manual/openid-conformance/README.md rename to tests/openid-conformance/README.md index 41b6fe7f9c..e352c96c8c 100644 --- a/tests/manual/openid-conformance/README.md +++ b/tests/openid-conformance/README.md @@ -1,7 +1,6 @@ # #Test files for OpenID Conformance testing. -These config files assume testing is being done using the [OpenID Conformance Suite -](https://openid.net/certification/about-conformance-suite/), locally. +These config files assume testing is being done using the [OpenID Conformance Suite](https://openid.net/certification/about-conformance-suite/), locally. See https://gitlab.com/openid/conformance-suite/-/wikis/Developers/Build-&-Run for running the conformance suite locally. diff --git a/tests/openid-conformance/compose.yml b/tests/openid-conformance/compose.yml new file mode 100644 index 0000000000..8f785506b9 --- /dev/null +++ b/tests/openid-conformance/compose.yml @@ -0,0 +1,32 @@ +services: + mongodb: + image: mongo:6.0.13 + volumes: + - mongo:/data/db + httpd: + image: ghcr.io/beryju/oidc-conformance-suite-httpd:v5.1.32 + ports: + - "8443:8443" + - "8444:8444" + depends_on: + - server + server: + image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.32 + ports: + - "9999:9999" + command: > + java + -Xdebug -Xrunjdwp:transport=dt_socket,address=*:9999,server=y,suspend=n + -jar /server/fapi-test-suite.jar + -Djdk.tls.maxHandshakeMessageSize=65536 + --fintechlabs.base_url=https://localhost.emobix.co.uk:8443 + --fintechlabs.base_mtls_url=https://localhost.emobix.co.uk:8444 + --fintechlabs.devmode=true + --fintechlabs.startredir=true + links: + - mongodb:mongodb + depends_on: + - mongodb + +volumes: + mongo: diff --git a/tests/openid-conformance/conformance.py b/tests/openid-conformance/conformance.py new file mode 100644 index 0000000000..7f64f9ce87 --- /dev/null +++ b/tests/openid-conformance/conformance.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# +# python wrapper for conformance suite API + + +import asyncio +import json +import os +import re +import time +import traceback +import zipfile + +import httpx + + +class RetryTransport(httpx.HTTPTransport): + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + retry = 0 + resp = None + while retry < 5: + retry += 1 + if retry > 2: + time.sleep(1) + try: + if resp is not None: + resp.close() + resp = super().handle_request(request) + except Exception as e: + print(f"httpx {request.url} exception {e} caught - retrying") + continue + if resp.status_code >= 500 and resp.status_code < 600: + print(f"httpx {request.url} 5xx response - retrying") + continue + content_type = resp.headers.get("Content-Type") + if content_type is not None: + mime_type, _, _ = content_type.partition(";") + if mime_type == "application/json": + try: + resp.read() + resp.json() + except Exception as e: + traceback.print_exc() + print( + f"httpx {request.url} response not decodable as json '{e}' - retrying" + ) + continue + break + return resp + + +class Conformance: + def __init__(self, api_url_base, api_token, verify_ssl): + if not api_url_base.endswith("/"): + api_url_base += "/" + self.api_url_base = api_url_base + transport = RetryTransport(verify=verify_ssl) + self.httpclient = httpx.Client(verify=verify_ssl, transport=transport, timeout=20) + headers = {"Content-Type": "application/json"} + if api_token is not None: + headers["Authorization"] = f"Bearer {api_token}" + self.httpclient.headers = headers + + async def get_all_test_modules(self): + """Returns an array containing a dictionary per test module""" + api_url = f"{self.api_url_base}api/runner/available" + response = self.httpclient.get(api_url) + + if response.status_code != 200: + raise Exception( + f"get_all_test_modules failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def exporthtml(self, plan_id, path): + for i in range(5): + api_url = f"{self.api_url_base}api/plan/exporthtml/{plan_id}" + try: + with self.httpclient.stream("GET", api_url) as response: + if response.status_code != 200: + raise Exception( + f"exporthtml failed - HTTP {response.status_code:d} {response.content}" + ) + d = response.headers["content-disposition"] + local_filename = re.findall('filename="(.+)"', d)[0] + full_path = os.path.join(path, local_filename) + with open(full_path, "wb") as f: + for chunk in response.iter_bytes(): + f.write(chunk) + zip_file = zipfile.ZipFile(full_path) + ret = zip_file.testzip() + if ret is not None: + raise Exception( + f"exporthtml for {plan_id} downloaded corrupt zip file {full_path} - {str(ret)}" + ) + return full_path + except Exception as e: + print(f"httpx {api_url} exception {e} caught - retrying") + await asyncio.sleep(1) + raise Exception(f"exporthtml for {plan_id} failed even after retries") + + async def create_certification_package( + self, plan_id, conformance_pdf_path, rp_logs_zip_path=None, output_zip_directory="./" + ): + """ + Create a complete certification package zip file which is written + to the directory specified by the 'output_zip_directory' parameter. + Calling this function will additionally publish and mark the test plan as immutable. + + :param plan_id: The plan id for which to create the package. + :conformance_pdf_path: The path to the signed Certification of Conformance PDF document. + :rp_logs_zip_path: Required for RP tests and is the path to the client logs zip file. + :output_zip_directory: The (already existing) directory to which the certification package zip file is written. + """ + certificationOfConformancePdf = open(conformance_pdf_path, "rb") + clientSideData = ( + open(rp_logs_zip_path, "rb") if rp_logs_zip_path is not None else open(os.devnull, "rb") + ) + files = { + "certificationOfConformancePdf": certificationOfConformancePdf, + "clientSideData": clientSideData, + } + try: + with httpx.Client() as multipartClient: + multipartClient.headers = self.httpclient.headers.copy() + multipartClient.headers.pop("content-type") + api_url = f"{self.api_url_base}api/plan/{plan_id}/certificationpackage" + + response = multipartClient.post(api_url, files=files) + if response.status_code != 200: + raise Exception( + f"certificationpackage failed - HTTP {response.status_code:d} {response.content}" + ) + + d = response.headers["content-disposition"] + local_filename = re.findall('filename="(.+)"', d)[0] + full_path = os.path.join(output_zip_directory, local_filename) + with open(full_path, "wb") as f: + for chunk in response.iter_bytes(): + f.write(chunk) + print( + f"Certification package zip for plan id {plan_id} written to {full_path}" + ) + finally: + certificationOfConformancePdf.close() + clientSideData.close() + + async def create_test_plan(self, name, configuration, variant=None): + api_url = f"{self.api_url_base}api/plan" + payload = {"planName": name} + if variant != None: + payload["variant"] = json.dumps(variant) + response = self.httpclient.post(api_url, params=payload, data=configuration) + + if response.status_code != 201: + raise Exception( + f"create_test_plan failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def create_test(self, test_name, configuration): + api_url = f"{self.api_url_base}api/runner" + payload = {"test": test_name} + response = self.httpclient.post(api_url, params=payload, data=configuration) + + if response.status_code != 201: + raise Exception( + f"create_test failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def create_test_from_plan(self, plan_id, test_name): + api_url = f"{self.api_url_base}api/runner" + payload = {"test": test_name, "plan": plan_id} + response = self.httpclient.post(api_url, params=payload) + + if response.status_code != 201: + raise Exception( + f"create_test_from_plan failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def create_test_from_plan_with_variant(self, plan_id, test_name, variant): + api_url = f"{self.api_url_base}api/runner" + payload = {"test": test_name, "plan": plan_id} + if variant != None: + payload["variant"] = json.dumps(variant) + response = self.httpclient.post(api_url, params=payload) + + if response.status_code != 201: + raise Exception( + f"create_test_from_plan failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def get_module_info(self, module_id): + api_url = f"{self.api_url_base}api/info/{module_id}" + response = self.httpclient.get(api_url) + + if response.status_code != 200: + raise Exception( + f"get_module_info failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def get_test_log(self, module_id): + api_url = f"{self.api_url_base}api/log/{module_id}" + response = self.httpclient.get(api_url) + + if response.status_code != 200: + raise Exception( + f"get_test_log failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def start_test(self, module_id): + api_url = f"{self.api_url_base}api/runner/{module_id}" + response = self.httpclient.post(api_url) + + if response.status_code != 200: + raise Exception( + f"start_test failed - HTTP {response.status_code:d} {response.content}" + ) + return response.json() + + async def wait_for_state(self, module_id, required_states, timeout=240): + timeout_at = time.time() + timeout + while True: + if time.time() > timeout_at: + raise Exception( + f"Timed out waiting for test module {module_id} to be in one of states: {required_states}" + ) + + info = await self.get_module_info(module_id) + + status = info["status"] + print(f"module id {module_id} status is {status}") + if status in required_states: + return status + if status == "INTERRUPTED": + raise Exception(f"Test module {module_id} has moved to INTERRUPTED") + + await asyncio.sleep(1) + + async def close_client(self): + self.httpclient.close() diff --git a/tests/manual/openid-conformance/oidc-conformance.yaml b/tests/openid-conformance/oidc-conformance.yaml similarity index 82% rename from tests/manual/openid-conformance/oidc-conformance.yaml rename to tests/openid-conformance/oidc-conformance.yaml index 54f3da1ff8..f39c8e68d1 100644 --- a/tests/manual/openid-conformance/oidc-conformance.yaml +++ b/tests/openid-conformance/oidc-conformance.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json version: 1 metadata: name: OIDC conformance testing @@ -34,12 +35,15 @@ entries: name: provider attrs: authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] issuer_mode: global client_id: 4054d882aff59755f2f279968b97ce8806a926e1 client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 - redirect_uris: | - https://localhost:8443/test/a/authentik/callback - https://localhost.emobix.co.uk:8443/test/a/authentik/callback + redirect_uris: + - matching_mode: strict + url: https://localhost:8443/test/a/authentik/callback + - matching_mode: strict + url: https://localhost.emobix.co.uk:8443/test/a/authentik/callback property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] @@ -60,12 +64,15 @@ entries: name: oidc-conformance-2 attrs: authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] issuer_mode: global client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 - redirect_uris: | - https://localhost:8443/test/a/authentik/callback - https://localhost.emobix.co.uk:8443/test/a/authentik/callback + redirect_uris: + - matching_mode: strict + url: https://localhost:8443/test/a/authentik/callback + - matching_mode: strict + url: https://localhost.emobix.co.uk:8443/test/a/authentik/callback property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] diff --git a/tests/openid-conformance/run.py b/tests/openid-conformance/run.py new file mode 100644 index 0000000000..20317c96eb --- /dev/null +++ b/tests/openid-conformance/run.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import os + +from conformance import Conformance + +CONFORMANCE_SERVER = "https://localhost:8443/" + +# This is the name of the Basic OP test plan: +test_plan_name = "oidcc-basic-certification-test-plan" + +# This is the variant configuration of the test, +# i.e. static or dynamic metadata location and client registration: +test_variant_config = {"server_metadata": "discovery", "client_registration": "static_client"} + +# This is the required configuration for the test run: +test_plan_config = { + "alias": "authentik", + "description": "authentik", + "server": { + "discoveryUrl": "http://10.120.20.76:9000/application/o/conformance/.well-known/openid-configuration" + }, + "client": { + "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", + "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867", + }, + "client_secret_post": { + "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", + "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867", + }, + "client2": { + "client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26", + "client_secret": "ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789", + }, + "consent": {}, + "browser": [ + { + "match": "http://10.120.20.76:9000/application/o/authorize*", + "tasks": [ + { + "task": "Login", + "optional": True, + "match": "http://10.120.20.76:9000/if/flow/default-authentication-flow*", + "commands": [ + ["wait", "css", "[name=uid_field]", 10], + ["text", "css", "[name=uid_field]", "akadmin"], + ["wait", "css", "button[type=submit]", 10], + ["click", "css", "button[type=submit]"], + ["wait", "css", "[name=password]", 10], + ["text", "css", "[name=password]", "foo"], + ["click", "css", "button[type=submit]"], + ["wait", "css", "#loading-text", 10], + ["wait", "contains", "application/o/authorize", 10], + ], + }, + { + "task": "Authorize", + "match": "http://10.120.20.76:9000/application/o/authorize*", + }, + { + "task": "Authorize 2", + "match": "http://10.120.20.76:9000/if/flow/default-provider-authorization-implicit-consent*", + }, + ], + } + ], +} + + +# Create a Conformance instance... +conformance = Conformance(CONFORMANCE_SERVER, None, verify_ssl=False) + +# Create a test plan instance and print the id of it +test_plan = asyncio.run( + conformance.create_test_plan(test_plan_name, json.dumps(test_plan_config), test_variant_config) +) +plan_id = test_plan["id"] + +print(f"----------------\nBegin {test_plan_name}.") +print(f"Plan URL: {CONFORMANCE_SERVER}plan-detail.html?plan={plan_id}\n") + +# Iterate over the tests in the plan and run them one by one +for test in test_plan["modules"]: + + # Fetch name and variant of the next test to run + module_name = test["testModule"] + variant = test["variant"] + print(f"Module name: {module_name}") + print(f"Variant: {json.dumps(variant)}") + + # Create an instance of that test + module_instance = asyncio.run( + conformance.create_test_from_plan_with_variant(plan_id, module_name, variant) + ) + module_id = module_instance["id"] + print(f"Test URL: {CONFORMANCE_SERVER}log-detail.html?log={module_id}") + + # Run the test and wait for it to finish + state = asyncio.run(conformance.wait_for_state(module_id, ["FINISHED"])) + print("") + +print(f"Plan URL: {CONFORMANCE_SERVER}plan-detail.html?plan={plan_id}\n") +print(f"\nEnd {test_plan_name}\n----------------") + +print("Creating certification package") +asyncio.run( + conformance.create_certification_package( + plan_id=plan_id, + conformance_pdf_path="OpenID-Certification-of-Conformance.pdf", + output_zip_directory="./zips/", + ) +) diff --git a/web/packages/sfe/src/index.ts b/web/packages/sfe/src/index.ts index 6d6372fe11..311718180d 100644 --- a/web/packages/sfe/src/index.ts +++ b/web/packages/sfe/src/index.ts @@ -1,20 +1,12 @@ import { fromByteArray } from "base64-js"; -import "formdata-polyfill"; +// 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"; + + +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: { @@ -67,15 +59,17 @@ class SimpleFlowExecutor { }); } - submit(data: { [key: string]: unknown } | FormData) { + submit(data: { [key: string]: unknown } | HTMLFormElement) { $("button[type=submit]").addClass("disabled") .html(` - Loading...`); + Loading...`); let finalData: { [key: string]: unknown } = {}; - if (data instanceof FormData) { - finalData = {}; - data.forEach((value, key) => { - finalData[key] = value; + if (data.tagName === "FORM") { + // @ts-expect-error + const rawData = $(data as unknown as string).serializeArray(); + + rawData.forEach((entry) => { + finalData[entry.name] = entry.value; }); } else { finalData = data; @@ -198,8 +192,7 @@ class IdentificationStage extends Stage { $("#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); + this.executor.submit(ev.target as HTMLFormElement); }); } } @@ -222,8 +215,7 @@ class PasswordStage extends Stage { $("#password-form input").trigger("focus"); $("#password-form").on("submit", (ev) => { ev.preventDefault(); - const data = new FormData(ev.target as HTMLFormElement); - this.executor.submit(data); + this.executor.submit(ev.target as HTMLFormElement); }); } } @@ -485,8 +477,7 @@ class AuthenticatorValidateStage extends Stage $("#totp-form input").trigger("focus"); $("#totp-form").on("submit", (ev) => { ev.preventDefault(); - const data = new FormData(ev.target as HTMLFormElement); - this.executor.submit(data); + this.executor.submit(ev.target as HTMLFormElement); }); }