diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift new file mode 100644 index 0000000..f9dbeae --- /dev/null +++ b/Apple/AppUITests/BurrowUITests.swift @@ -0,0 +1,232 @@ +import XCTest + +@MainActor +final class BurrowTailnetLoginUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testTailnetLoginThroughAuthentikWebSession() throws { + let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") + let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email + let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") + + let app = XCUIApplication() + app.launch() + + let tailnetButton = app.buttons["quick-add-tailnet"] + XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") + tailnetButton.tap() + + let discoveryField = app.textFields["tailnet-discovery-email"] + XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") + replaceText(in: discoveryField, with: email) + + let findServerButton = app.buttons["tailnet-find-server"] + XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") + findServerButton.tap() + + let discoveryCard = app.otherElements["tailnet-discovery-card"] + XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear") + + let authorityField = app.textFields["tailnet-authority"] + XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") + XCTAssertTrue( + waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20), + "Tailnet authority was not populated from discovery" + ) + + let probeButton = app.buttons["tailnet-check-connection"] + XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear") + probeButton.tap() + + let probeCard = app.otherElements["tailnet-authority-probe-card"] + XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete") + + let signInButton = app.buttons["tailnet-start-sign-in"] + XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") + signInButton.tap() + + acceptAuthenticationPromptIfNeeded(in: app) + + let webSession = webAuthenticationSession() + XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") + + signIntoAuthentik(in: webSession, username: username, password: password) + + app.activate() + XCTAssertTrue( + waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60), + "Tailnet sign-in never reached the running state" + ) + } + + private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let promptCandidates = [ + springboard.buttons["Continue"], + springboard.buttons["Allow"], + app.buttons["Continue"], + app.buttons["Allow"], + ] + + for button in promptCandidates where button.waitForExistence(timeout: 3) { + button.tap() + return + } + } + + private func webAuthenticationSession() -> XCUIApplication { + let safariViewService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService") + if safariViewService.waitForExistence(timeout: 5) { + return safariViewService + } + + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + _ = safari.waitForExistence(timeout: 5) + return safari + } + + private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { + let usernameField = firstExistingElement( + in: webSession, + queries: [ + { $0.textFields["Username"] }, + { $0.textFields["Email or Username"] }, + { $0.textFields["Email address"] }, + { $0.textFields["Email"] }, + { $0.webViews.textFields["Username"] }, + { $0.webViews.textFields["Email or Username"] }, + { $0.descendants(matching: .textField).firstMatch }, + ], + timeout: 25 + ) + XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") + replaceText(in: usernameField, with: username) + + let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) + if immediatePasswordField.exists { + replaceSecureText(in: immediatePasswordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + return + } + + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Next", "Sign In", "Log in", "Login"], + timeout: 5 + ) + + let passwordField = firstExistingSecureField(in: webSession, timeout: 20) + XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") + replaceSecureText(in: passwordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + } + + private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { + let candidates = [ + app.secureTextFields["Password"], + app.secureTextFields["Password or Token"], + app.webViews.secureTextFields["Password"], + app.webViews.secureTextFields["Password or Token"], + app.descendants(matching: .secureTextField).firstMatch, + ] + + return firstExistingElement(from: candidates, timeout: timeout) + } + + private func tapFirstExistingButton( + in app: XCUIApplication, + titles: [String], + timeout: TimeInterval + ) { + let candidates = titles.flatMap { title in + [ + app.buttons[title], + app.webViews.buttons[title], + ] + } + [app.descendants(matching: .button).firstMatch] + + let button = firstExistingElement(from: candidates, timeout: timeout) + XCTAssertTrue(button.exists, "Expected one of \(titles.joined(separator: ", ")) to appear") + button.tap() + } + + private func requiredEnvironment(_ key: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[key], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw XCTSkip("Missing required UI test environment variable \(key)") + } + return value + } + + private func waitForFieldValue( + _ field: XCUIElement, + containing substring: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "value CONTAINS %@", substring) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: field) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func waitForButtonLabel( + _ button: XCUIElement, + equals expected: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "label == %@", expected) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func firstExistingElement( + in app: XCUIApplication, + queries: [(XCUIApplication) -> XCUIElement], + timeout: TimeInterval + ) -> XCUIElement { + firstExistingElement(from: queries.map { $0(app) }, timeout: timeout) + } + + private func firstExistingElement(from candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + repeat { + for candidate in candidates where candidate.exists { + return candidate + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + + return candidates[0] + } + + private func replaceText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func replaceSecureText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func clearText(in element: XCUIElement) { + guard let currentValue = element.value as? String, !currentValue.isEmpty else { + return + } + + let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) + element.typeText(deleteSequence) + } +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 9897f79..83d32e0 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; + D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; @@ -49,6 +50,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + D11000022F70000100112233 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; + remoteInfo = App; + }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -130,6 +138,9 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; + D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -182,6 +193,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + D11000062F70000100112233 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -243,6 +261,7 @@ D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, + D11000052F70000100112233 /* UITests.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, D0D4E5912C8D9D0A007F820A /* Constants */, @@ -268,6 +287,7 @@ isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, + D11000072F70000100112233 /* AppUITests */, D020F65629E4A697002790F6 /* NetworkExtension */, D0D4E49C2C8D921A007F820A /* Core */, D0D4E4AD2C8D921A007F820A /* UI */, @@ -281,6 +301,7 @@ isa = PBXGroup; children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, + D11000032F70000100112233 /* BurrowUITests.xctest */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, D0BCC6032A09535900AD070D /* libburrow.a */, D0D4E5312C8D996F007F820A /* BurrowCore.framework */, @@ -303,6 +324,14 @@ path = App; sourceTree = ""; }; + D11000072F70000100112233 /* AppUITests */ = { + isa = PBXGroup; + children = ( + D11000042F70000100112233 /* BurrowUITests.swift */, + ); + path = AppUITests; + sourceTree = ""; + }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -375,6 +404,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D11000082F70000100112233 /* BurrowUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; + buildPhases = ( + D110000A2F70000100112233 /* Sources */, + D11000062F70000100112233 /* Frameworks */, + D11000092F70000100112233 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D110000B2F70000100112233 /* PBXTargetDependency */, + ); + name = BurrowUITests; + productName = BurrowUITests; + productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -490,6 +537,10 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { + D11000082F70000100112233 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = D05B9F7129E39EEC008CB1F9; + }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; @@ -522,6 +573,7 @@ projectRoot = ""; targets = ( D05B9F7129E39EEC008CB1F9 /* App */, + D11000082F70000100112233 /* BurrowUITests */, D020F65229E4A697002790F6 /* NetworkExtension */, D0D4E5502C8D9BF2007F820A /* UI */, D0D4E5302C8D996F007F820A /* Core */, @@ -531,6 +583,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + D11000092F70000100112233 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -594,6 +653,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + D110000A2F70000100112233 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D11000012F70000100112233 /* BurrowUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -652,6 +719,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + D110000B2F70000100112233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D05B9F7129E39EEC008CB1F9 /* App */; + targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; + }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -694,6 +766,20 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + D110000C2F70000100112233 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D110000D2F70000100112233 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Release; + }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -781,6 +867,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D110000C2F70000100112233 /* Debug */, + D110000D2F70000100112233 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index a524e87..f580ea7 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -28,7 +28,20 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldAutocreateTestPlan = "NO"> + + + + + + some View { diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh index 656b738..277c5f4 100644 --- a/Scripts/authentik-sync-burrow-directory.sh +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -116,7 +116,7 @@ lookup_user_pk() { ensure_user() { local user_spec="$1" - local username name email is_admin groups_json effective_groups_json group_name + local username name email is_admin groups_json password_file effective_groups_json group_name local group_pks_json payload user_pk username="$(printf '%s\n' "$user_spec" | jq -r '.username')" @@ -124,6 +124,7 @@ ensure_user() { email="$(printf '%s\n' "$user_spec" | jq -r '.email')" is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" + password_file="$(printf '%s\n' "$user_spec" | jq -r '.passwordFile // empty')" if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then echo "error: each Burrow Authentik user requires username and email" >&2 @@ -178,6 +179,19 @@ ensure_user() { echo "error: could not create Authentik user ${username}" >&2 exit 1 fi + + if [[ -n "$password_file" ]]; then + if [[ ! -s "$password_file" ]]; then + echo "error: password file for Authentik user ${username} is missing: ${password_file}" >&2 + exit 1 + fi + + api POST "/api/v3/core/users/${user_pk}/set_password/" "$( + jq -cn \ + --arg password "$(tr -d '\r\n' < "$password_file")" \ + '{password: $password}' + )" >/dev/null + fi } lookup_application_pk() { diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh new file mode 100755 index 0000000..bfb00ef --- /dev/null +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" +authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" +authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" +identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" +password_stage_name="${AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME:-burrow-tailnet-password-stage}" +user_login_stage_name="${AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME:-burrow-tailnet-user-login-stage}" +google_source_slug="${AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG:-google}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailnet-auth-flow.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILNET_PROVIDER_SLUG + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG + AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME + AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME + AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME + AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_stage_by_name() { + local path="$1" + local name="$2" + + api GET "${path}?page_size=200" \ + | jq -c --arg name "$name" '.results[]? | select(.name == $name)' \ + | head -n1 +} + +lookup_flow_pk() { + local slug="$1" + + api GET "/api/v3/flows/instances/?slug=${slug}" \ + | jq -r '.results[]? | select(.slug != null) | .pk // empty' \ + | head -n1 +} + +lookup_source_pk() { + local slug="$1" + + api GET "/api/v3/sources/oauth/?page_size=200&slug=${slug}" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_password_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/password/" "$password_stage_name")" + payload="$( + jq -cn \ + --arg name "$password_stage_name" \ + '{ + name: $name, + backends: [ + "authentik.core.auth.InbuiltBackend", + "authentik.core.auth.TokenBackend" + ], + allow_show_password: false, + failed_attempts_before_cancel: 5 + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/password/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/password/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_identification_stage() { + local password_stage_pk="$1" + local google_source_pk="$2" + local existing payload stage_pk sources_json + + existing="$(lookup_stage_by_name "/api/v3/stages/identification/" "$identification_stage_name")" + if [[ -n "$google_source_pk" ]]; then + sources_json="$(jq -cn --arg source "$google_source_pk" '[$source]')" + else + sources_json='[]' + fi + + payload="$( + jq -cn \ + --arg name "$identification_stage_name" \ + --arg password_stage "$password_stage_pk" \ + --argjson sources "$sources_json" \ + '{ + name: $name, + user_fields: ["username", "email"], + password_stage: $password_stage, + case_insensitive_matching: true, + show_matched_user: true, + sources: $sources, + show_source_labels: true, + pretend_user_exists: false, + enable_remember_me: false + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/identification/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/identification/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_user_login_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/user_login/" "$user_login_stage_name")" + payload="$( + jq -cn \ + --arg name "$user_login_stage_name" \ + '{ + name: $name, + session_duration: "hours=12", + terminate_other_sessions: false, + remember_me_offset: "seconds=0", + network_binding: "no_binding", + geoip_binding: "no_binding" + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/user_login/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/user_login/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_authentication_flow() { + local existing_pk payload + + existing_pk="$(lookup_flow_pk "$authentication_flow_slug")" + payload="$( + jq -cn \ + --arg name "$authentication_flow_name" \ + --arg slug "$authentication_flow_slug" \ + '{ + name: $name, + title: $name, + slug: $slug, + designation: "authentication", + policy_engine_mode: "any", + layout: "stacked" + }' + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/flows/instances/${authentication_flow_slug}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/flows/instances/" "$payload" \ + | jq -r '.pk // empty' + fi +} + +ensure_flow_binding() { + local flow_pk="$1" + local stage_pk="$2" + local order="$3" + local existing payload binding_pk + + existing="$( + api GET "/api/v3/flows/bindings/?target=${flow_pk}&stage=${stage_pk}&page_size=200" \ + | jq -c '.results[]?' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$flow_pk" \ + --arg stage "$stage_pk" \ + --argjson order "$order" \ + '{ + target: $target, + stage: $stage, + order: $order, + policy_engine_mode: "any" + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/flows/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/flows/bindings/" "$payload" >/dev/null + fi +} + +wait_for_authentik + +provider_pk="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg provider_slug "$provider_slug" ' + .results[]? + | select(.assigned_application_slug == $provider_slug or .slug == $provider_slug) + | .pk // empty + ' \ + | head -n1 +)" + +if [[ -z "$provider_pk" ]]; then + echo "error: could not resolve Authentik Tailnet OAuth provider ${provider_slug}" >&2 + exit 1 +fi + +google_source_pk="$(lookup_source_pk "$google_source_slug" || true)" +password_stage_pk="$(ensure_password_stage)" +identification_stage_pk="$(ensure_identification_stage "$password_stage_pk" "$google_source_pk")" +user_login_stage_pk="$(ensure_user_login_stage)" +authentication_flow_pk="$(ensure_authentication_flow)" + +ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 +ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 + +api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( + jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' +)" >/dev/null + +echo "Synced Burrow Tailnet authentication flow for provider ${provider_slug}." diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh new file mode 100755 index 0000000..5086bd1 --- /dev/null +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" +simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" +derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" +source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" +fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback" +socket_path="${fallback_dir}/burrow.sock" +daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" +ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" +ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" +password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" +age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" + +ui_test_password="${BURROW_UI_TEST_PASSWORD:-}" +if [[ -z "$ui_test_password" ]]; then + if [[ -f "$password_secret" && -f "$age_identity" ]]; then + ui_test_password="$(age -d -i "$age_identity" "$password_secret" | tr -d '\r\n')" + else + echo "error: BURROW_UI_TEST_PASSWORD is unset and ${password_secret} could not be decrypted" >&2 + exit 1 + fi +fi + +mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path" +rm -f "$socket_path" + +cleanup() { + if [[ -n "${daemon_pid:-}" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + wait "$daemon_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +cargo build -p burrow --bin burrow + +( + cd "$fallback_dir" + BURROW_SOCKET_PATH="burrow.sock" \ + "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in $(seq 1 50); do + [[ -S "$socket_path" ]] && break + sleep 0.2 +done + +if [[ ! -S "$socket_path" ]]; then + echo "error: Burrow daemon did not create ${socket_path}" >&2 + [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 + exit 1 +fi + +BURROW_UI_TEST_EMAIL="$ui_test_email" \ +BURROW_UI_TEST_USERNAME="$ui_test_username" \ +BURROW_UI_TEST_PASSWORD="$ui_test_password" \ +xcodebuild \ + -quiet \ + -skipPackagePluginValidation \ + -project "${repo_root}/Apple/Burrow.xcodeproj" \ + -scheme App \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \ + -derivedDataPath "$derived_data_path" \ + -clonedSourcePackagesDirPath "$source_packages_path" \ + -only-testing:BurrowUITests \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/contributors.nix b/contributors.nix index f6cc014..22c28b6 100644 --- a/contributors.nix +++ b/contributors.nix @@ -43,5 +43,18 @@ "automation" ]; }; + + ui-test = { + displayName = "Burrow UI Test"; + canonicalEmail = "ui-test@burrow.net"; + isAdmin = false; + forgeAuthorized = false; + bootstrapAuthentik = true; + authentikPasswordSecret = "burrowAuthentikUiTestPassword"; + roles = [ + "testing" + "apple-ui" + ]; + }; }; } diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index fb5b8ae..6c106f4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,10 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + authentikPasswordSecretPath = identity: + if identity ? authentikPasswordSecret + then config.age.secrets.${identity.authentikPasswordSecret}.path + else null; bootstrapUsers = lib.mapAttrsToList ( username: identity: { @@ -11,6 +15,7 @@ let email = identity.canonicalEmail; sourceEmail = identity.sourceEmail or null; isAdmin = identity.isAdmin or false; + passwordFile = authentikPasswordSecretPath identity; } ) (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); @@ -70,6 +75,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikUiTestPassword = { + file = ../../../secrets/infra/authentik-ui-test-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 4e31d43..478d0d9 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -11,6 +11,7 @@ let directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; + tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -175,6 +176,36 @@ in description = "Identification-stage behavior for the Google Authentik source."; }; + headscaleAuthenticationFlowSlug = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-authentication"; + description = "Authentik authentication flow slug used for Burrow Tailnet sign-in."; + }; + + headscaleAuthenticationFlowName = lib.mkOption { + type = lib.types.str; + default = "Burrow Tailnet Authentication"; + description = "Authentik authentication flow name used for Burrow Tailnet sign-in."; + }; + + headscaleIdentificationStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-identification-stage"; + description = "Authentik identification stage used for Burrow Tailnet sign-in."; + }; + + headscalePasswordStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-password-stage"; + description = "Authentik password stage used for Burrow Tailnet sign-in."; + }; + + headscaleUserLoginStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-user-login-stage"; + description = "Authentik user-login stage used for Burrow Tailnet sign-in."; + }; + userGroupName = lib.mkOption { type = lib.types.str; default = "burrow-users"; @@ -217,6 +248,11 @@ in default = false; description = "Whether this user should be in the Burrow admin group."; }; + passwordFile = lib.mkOption { + type = nullOr str; + default = null; + description = "Optional host-local file containing a bootstrap password for this user."; + }; }; }); default = [ ]; @@ -468,7 +504,7 @@ EOF restartTriggers = [ directorySyncScript cfg.envFile - ]; + ] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers; path = [ pkgs.bash pkgs.coreutils @@ -491,7 +527,7 @@ EOF export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { - inherit (user) username name email isAdmin; + inherit (user) username name email isAdmin passwordFile; groups = user.groups; }) cfg.bootstrapUsers)}' @@ -499,6 +535,59 @@ EOF ''; }; + systemd.services.burrow-authentik-tailnet-auth-flow = { + description = "Reconcile the Burrow Tailnet authentication flow"; + after = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wants = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailnetAuthFlowSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} + export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} + export AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME=${lib.escapeShellArg cfg.headscalePasswordStageName} + export AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME=${lib.escapeShellArg cfg.headscaleUserLoginStageName} + export AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} + + ${pkgs.bash}/bin/bash ${tailnetAuthFlowSyncScript} + ''; + }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; after = [ diff --git a/secrets.nix b/secrets.nix index 909b929..cc23605 100644 --- a/secrets.nix +++ b/secrets.nix @@ -12,6 +12,7 @@ in "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-ui-test-password.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age new file mode 100644 index 0000000..f39c21a --- /dev/null +++ b/secrets/infra/authentik-ui-test-password.age @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 4+zOIEyQTCHqKdZKV/H4D7e4y+UTrc9rYzvCgGUPVEg +S+tAlc4wvzVUe9r9+mBAnUj5C31bQqo4PK3muBCzs2Y +-> ssh-ed25519 IrZmAg 1KasjHiY1MQVLIzoDdGshhDhaDimOtZ5EyE4GyZngHg +ov711Sp+Q/zQw0NUpB2rnKEF8bFxoVafdVQ/8gSbSZA +-> X25519 3EWdCP5UkWd1g6bDaQm/kNCNlhSONrz8RB7OZgT9nXE +6+HoM9mg6P/CtU39P8SCyutLkmYw27MikoZZ5L9nI54 +--- Rw0o+MvtvHQrrYPNtCPxHGR67K67nyJUQRd4DN3nOCY +fn