diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d36a54b..239ab81 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -168,6 +168,7 @@ Healthy v2 bootstrap state usually looks like: ```bash apw login https://example.com +apw fill https://example.com ``` ### External password manager fallback diff --git a/docs/NATIVE_ONLY_REDESIGN.md b/docs/NATIVE_ONLY_REDESIGN.md index 5751849..d53b912 100644 --- a/docs/NATIVE_ONLY_REDESIGN.md +++ b/docs/NATIVE_ONLY_REDESIGN.md @@ -215,13 +215,20 @@ Deliverables: main thread and bridges results back to the worker thread via `DispatchSemaphore`, and a stable `BrokerErrorCode` mapping (`canceled` / `failed` / `invalidResponse` / `notHandled` / `unknown`). -- `BrokerCore.loginResponse` routes through the injected broker when - `APW_DEMO` is unset, mapping outcomes onto the existing wire envelope - (`transport: "authentication_services"`, `userMediated: true`, integer - status codes that match the Rust `Status` enum). + SDK-specific and future `ASAuthorizationError` cases are intentionally + collapsed into `unknown` unless APW promotes them into a stable wire + code. +- `BrokerCore` routes both `login` and `fill` through the injected broker + when `APW_DEMO` is unset, mapping outcomes onto the existing wire + envelope (`transport: "authentication_services"`, `userMediated: true`, + request `intent`, and integer status codes that match the Rust `Status` + enum). - `BrokerCoreTests` exercises the broker outcome paths via - `StubCredentialBroker` for `success` / `denied` / `canceled` / - `invalidResponse`, and asserts the broker error code mapping. + `StubCredentialBroker` / `RecordingCredentialBroker` for `login` and + `fill` success, denial, cancellation, `notHandled`, `invalidResponse`, + the non-demo no-credential-source path, and the rule that + `credentials.json` is not read outside `APW_DEMO=1`. The tests also + assert the broker error code mapping. **Phase 3 exit blockers still open**: diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index a619fd3..0d23d84 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -39,6 +39,10 @@ Release reference version: `v2.0.0` - native app UNIX-socket requests use a `3s` read/write timeout - a hung broker socket returns a non-zero `CommunicationTimeout` error instead of blocking the CLI indefinitely +- AuthenticationServices returns stable APW broker codes for cancellation, + generic failure, invalid response, not-handled, and unknown errors; + SDK-specific cases are collapsed into `unknown` until APW explicitly + promotes them into the wire contract - direct executable fallback runs the APW app bundle under a `5s` wall-clock timeout, reads at most the configured maximum response size from each of stdout and stderr via bounded pipe reads, and terminates the child (process @@ -95,6 +99,9 @@ The native app Swift test suite covers: - localized approval prompt copy for APW-owned UI - accessibility labels for the credential approval window and buttons - broker envelope parsing, permission checks, denial handling, and typed AuthenticationServices fallback errors +- AuthenticationServices broker routing for `login` and `fill` success and + failure cases via injected test brokers, including the guarantee that + `credentials.json` is not consulted unless `APW_DEMO=1` ## Accessibility and localization audit diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift index 76f28fe..067b0d7 100644 --- a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -154,7 +154,9 @@ public protocol CredentialBroker { return .notHandled case .unknown: return .unknown - @unknown default: + default: + // SDK-specific and future AuthenticationServices cases are + // intentionally collapsed into the stable wire-level bucket. return .unknown } } diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index d04e461..fefbd03 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -374,7 +374,10 @@ final class BrokerServer { ) case "login": let url = request.payload?["url"] ?? "" - return try loginResponse(for: url, requestId: request.requestId) + return try loginResponse(for: url, requestId: request.requestId, intent: "login") + case "fill": + let url = request.payload?["url"] ?? "" + return try loginResponse(for: url, requestId: request.requestId, intent: "fill") default: return ResponseEnvelope( ok: false, @@ -413,7 +416,11 @@ final class BrokerServer { ] } - private func loginResponse(for rawURL: String, requestId: String?) throws -> ResponseEnvelope { + private func loginResponse( + for rawURL: String, + requestId: String?, + intent: String + ) throws -> ResponseEnvelope { guard let url = URL(string: rawURL), let host = url.host?.lowercased(), !host.isEmpty else { return ResponseEnvelope( ok: false, @@ -452,6 +459,7 @@ final class BrokerServer { "password": AnyCodable(credential.password), "transport": AnyCodable("authentication_services"), "userMediated": AnyCodable(true), + "intent": AnyCodable(intent), ], error: nil, requestId: requestId @@ -519,6 +527,7 @@ final class BrokerServer { "password": AnyCodable(credential.password), "transport": AnyCodable("unix_socket"), "userMediated": AnyCodable(true), + "intent": AnyCodable(intent), ], error: nil, requestId: requestId diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index ad42d85..12437eb 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -19,6 +19,20 @@ private struct StubCredentialBroker: CredentialBroker { } } +private final class RecordingCredentialBroker: CredentialBroker { + let outcome: CredentialBrokerResult + private(set) var requestedURLs: [String] = [] + + init(outcome: CredentialBrokerResult) { + self.outcome = outcome + } + + func login(url: String) -> CredentialBrokerResult { + requestedURLs.append(url) + return outcome + } +} + final class BrokerCoreTests: XCTestCase { private func makePaths(_ root: URL) -> AppPaths { AppPaths( @@ -270,11 +284,70 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(response.ok, true) XCTAssertEqual(response.code, 0) XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "login") + XCTAssertEqual(response.payload?["username"]?.value as? String, "alice@example.com") + XCTAssertEqual(response.payload?["domain"]?.value as? String, "vault.example.com") + XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) + } + + func testFillRoutesToCredentialBrokerOnSuccess() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com/login", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "fill-ok", + command: "fill", + payload: ["url": "https://vault.example.com/login"] + )) + + XCTAssertEqual(response.ok, true) + XCTAssertEqual(response.code, 0) + XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "fill") XCTAssertEqual(response.payload?["username"]?.value as? String, "alice@example.com") XCTAssertEqual(response.payload?["domain"]?.value as? String, "vault.example.com") XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) } + func testCredentialBrokerPathDoesNotReadCredentialsFileOutsideDemoMode() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let paths = makePaths(root) + try writeCredentials(at: paths.credentialsPath, mode: 0o644) + let broker = RecordingCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "non-demo", + command: "login", + payload: ["url": "https://vault.example.com"] + )) + + XCTAssertEqual(response.ok, true) + XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(broker.requestedURLs, ["https://vault.example.com"]) + } + func testLoginRoutesToCredentialBrokerOnDeny() throws { unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) @@ -294,6 +367,54 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(response.error, "User denied the APW login request.") } + func testFillRoutesCredentialBrokerFailures() throws { + struct Scenario { + let outcome: CredentialBrokerResult + let expectedCode: Int + let expectedError: String + } + + let scenarios = [ + Scenario( + outcome: .denied, + expectedCode: 1, + expectedError: "User denied the APW login request."), + Scenario( + outcome: .failure(.canceled, "ASAuthorizationError canceled"), + expectedCode: 1, + expectedError: "canceled"), + Scenario( + outcome: .failure(.notHandled, "ASAuthorizationError notHandled"), + expectedCode: 3, + expectedError: "notHandled"), + Scenario( + outcome: .failure(.invalidResponse, "ASAuthorizationError invalidResponse"), + expectedCode: 104, + expectedError: "invalidResponse"), + ] + + for (index, scenario) in scenarios.enumerated() { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker(outcome: scenario.outcome) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "fill-failure-\(index)", + command: "fill", + payload: ["url": "https://vault.example.com/login"] + )) + + XCTAssertEqual(response.ok, false, "scenario \(index)") + XCTAssertEqual(response.code, scenario.expectedCode, "scenario \(index)") + XCTAssertTrue( + response.error?.contains(scenario.expectedError) ?? false, + "scenario \(index) expected \(scenario.expectedError), got: \(response.error ?? "nil")") + } + } + func testLoginRoutesToCredentialBrokerOnCanceled() throws { unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory())