Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions docs/NATIVE_ONLY_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down
7 changes: 7 additions & 0 deletions docs/SECURITY_POSTURE_AND_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
13 changes: 11 additions & 2 deletions native-app/Sources/NativeAppLib/BrokerCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions native-app/Tests/NativeAppTests/BrokerCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand Down
Loading