Skip to content

Commit

Permalink
[PM-6686] Show WebAuthn Connector webpage for self-hosted vaults (#651)
Browse files Browse the repository at this point in the history
  • Loading branch information
KatherineInCode authored Jun 3, 2024
1 parent 6904487 commit 6eee319
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 37 deletions.
20 changes: 20 additions & 0 deletions BitwardenShared/Core/Auth/Services/AuthService.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AuthenticationServices
import BitwardenSdk
import CryptoKit
import Foundation
Expand Down Expand Up @@ -198,6 +199,14 @@ protocol AuthService {
/// - userId: The user ID associated with the pending admin login request.
///
func setPendingAdminLoginRequest(_ adminLoginRequest: PendingAdminLoginRequest?, userId: String?) async throws

/// Provides a web authentication session. In practice this is a passthrough
/// for `ASWebAuthenticationSession.init`.
///
func webAuthenticationSession(
url: URL,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
) -> ASWebAuthenticationSession
}

extension AuthService {
Expand Down Expand Up @@ -682,6 +691,17 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
}
}

func webAuthenticationSession(
url: URL,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
) -> ASWebAuthenticationSession {
ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackUrlScheme,
completionHandler: completionHandler
)
}

// MARK: Private Methods

/// Get the fingerprint phrase from the public key of a login request.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AuthenticationServices
import BitwardenSdk
import Foundation

Expand Down Expand Up @@ -63,6 +64,8 @@ class MockAuthService: AuthService {
var setPendingAdminLoginRequest: PendingAdminLoginRequest?
var setPendingAdminLoginRequestResult: Result<Void, Error> = .success(())

var webAuthenticationSession: ASWebAuthenticationSession?

func answerLoginRequest(_ request: LoginRequest, approve: Bool) async throws {
answerLoginRequestRequest = request
answerLoginRequestApprove = approve
Expand Down Expand Up @@ -166,4 +169,44 @@ class MockAuthService: AuthService {
setPendingAdminLoginRequest = adminLoginRequest
try setPendingAdminLoginRequestResult.get()
}

func webAuthenticationSession(
url: URL,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
) -> ASWebAuthenticationSession {
let mockSession = MockWebAuthenticationSession(
url: url,
callbackURLScheme: callbackUrlScheme,
completionHandler: completionHandler
)
webAuthenticationSession = mockSession
return mockSession
}
}

// MARK: - MockWebAuthenticationSession

class MockWebAuthenticationSession: ASWebAuthenticationSession {
var startCalled = false
var startReturn = true

var initUrl: URL
var initCallbackURLScheme: String?
var initCompletionHandler: ASWebAuthenticationSession.CompletionHandler

override init(
url URL: URL,
callbackURLScheme: String?,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
) {
initUrl = URL
initCallbackURLScheme = callbackURLScheme
initCompletionHandler = completionHandler
super.init(url: URL, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler)
}

override func start() -> Bool {
startCalled = true
return startReturn
}
}
13 changes: 13 additions & 0 deletions BitwardenShared/Core/Platform/Services/EnvironmentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ protocol EnvironmentService {
/// The URL for the recovery code help page.
var recoveryCodeURL: URL { get }

/// The region of the current environment.
var region: RegionType { get }

/// The URL for sharing a send.
var sendShareURL: URL { get }

Expand Down Expand Up @@ -118,6 +121,16 @@ extension DefaultEnvironmentService {
environmentUrls.recoveryCodeURL
}

var region: RegionType {
if environmentUrls.baseURL == EnvironmentUrlData.defaultUS.base {
return .unitedStates
} else if environmentUrls.baseURL == EnvironmentUrlData.defaultEU.base {
return .europe
} else {
return .selfHosted
}
}

var sendShareURL: URL {
environmentUrls.sendShareURL
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class EnvironmentServiceTests: XCTestCase {
XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.com/icons"))
XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.com/identity"))
XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.com/#/tools/import"))
XCTAssertEqual(subject.region, .unitedStates)
XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.com/#/send"))
XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.com/#/settings"))
XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.com"))
Expand All @@ -53,11 +54,32 @@ class EnvironmentServiceTests: XCTestCase {
XCTAssertEqual(subject.iconsURL, URL(string: "https://example.com/icons"))
XCTAssertEqual(subject.identityURL, URL(string: "https://example.com/identity"))
XCTAssertEqual(subject.importItemsURL, URL(string: "https://example.com/#/tools/import"))
XCTAssertEqual(subject.region, .selfHosted)
XCTAssertEqual(subject.sendShareURL, URL(string: "https://example.com/#/send"))
XCTAssertEqual(subject.settingsURL, URL(string: "https://example.com/#/settings"))
XCTAssertEqual(subject.webVaultURL, URL(string: "https://example.com"))
}

/// `loadURLsForActiveAccount()` handles EU URLs
func test_loadURLsForActiveAccount_europe() async {
let urls = EnvironmentUrlData.defaultEU
let account = Account.fixture(settings: .fixture(environmentUrls: urls))
stateService.activeAccount = account
stateService.environmentUrls = [account.profile.userId: urls]

await subject.loadURLsForActiveAccount()

XCTAssertEqual(subject.apiURL, URL(string: "https://vault.bitwarden.eu/api"))
XCTAssertEqual(subject.eventsURL, URL(string: "https://vault.bitwarden.eu/events"))
XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.eu/icons"))
XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.eu/identity"))
XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.eu/#/tools/import"))
XCTAssertEqual(subject.region, .europe)
XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.eu/#/send"))
XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.eu/#/settings"))
XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.eu"))
}

/// `loadURLsForActiveAccount()` loads the default URLs if there's no active account.
func test_loadURLsForActiveAccount_noAccount() async {
await subject.loadURLsForActiveAccount()
Expand All @@ -67,6 +89,7 @@ class EnvironmentServiceTests: XCTestCase {
XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.com/icons"))
XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.com/identity"))
XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.com/#/tools/import"))
XCTAssertEqual(subject.region, .unitedStates)
XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.com/#/send"))
XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.com/#/settings"))
XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.com"))
Expand All @@ -83,6 +106,7 @@ class EnvironmentServiceTests: XCTestCase {
XCTAssertEqual(subject.iconsURL, URL(string: "https://example.com/icons"))
XCTAssertEqual(subject.identityURL, URL(string: "https://example.com/identity"))
XCTAssertEqual(subject.importItemsURL, URL(string: "https://example.com/#/tools/import"))
XCTAssertEqual(subject.region, .selfHosted)
XCTAssertEqual(subject.sendShareURL, URL(string: "https://example.com/#/send"))
XCTAssertEqual(subject.settingsURL, URL(string: "https://example.com/#/settings"))
XCTAssertEqual(subject.webVaultURL, URL(string: "https://example.com"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class MockEnvironmentService: EnvironmentService {
var identityURL = URL(string: "https://example.com/identity")!
var importItemsURL = URL(string: "https://example.com/#/tools/import")!
var recoveryCodeURL = URL(string: "https://example.com/#/recover-2fa")!
var region = RegionType.selfHosted
var sendShareURL = URL(string: "https://example.com/#/send")!
var settingsURL = URL(string: "https://example.com/#/settings")!
var webVaultURL = URL(string: "https://example.com")!
Expand Down
83 changes: 62 additions & 21 deletions BitwardenShared/UI/Auth/AuthCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
credentialIds: allowCredentialIds,
userVerificationPreference: userVerificationPreference
)
case let .webAuthnSelfHosted(url):
showWebAuthnSelfHosted(authURL: url, delegate: context as? WebAuthnFlowDelegate)
case let .vaultUnlock(
account,
animated,
Expand Down Expand Up @@ -567,6 +569,34 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
stackNavigator?.present(navigationController)
}

/// Shows the vault unlock view.
///
/// - Parameters:
/// - account: The active account.
/// - animated: Whether to animate the transition.
/// - attemptAutmaticBiometricUnlock: Whether to the processor should attempt a biometric unlock on appear.
/// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically.
///
private func showVaultUnlock(
account: Account,
animated: Bool,
attemptAutmaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
) {
let processor = VaultUnlockProcessor(
appExtensionDelegate: appExtensionDelegate,
coordinator: asAnyCoordinator(),
services: services,
state: VaultUnlockState(account: account)
)
processor.shouldAttemptAutomaticBiometricUnlock = attemptAutmaticBiometricUnlock
let view = VaultUnlockView(store: Store(processor: processor))
stackNavigator?.replace(view, animated: animated)
if didSwitchAccountAutomatically {
processor.state.toast = Toast(text: Localizations.accountSwitchedAutomatically)
}
}

/// Show the WebAuthn two factor authentication view.
///
/// - Parameters:
Expand Down Expand Up @@ -604,32 +634,43 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
authController.performRequests()
}

/// Shows the vault unlock view.
/// Show the WebAuthn connector web page for self-hosted vaults.
///
/// - Parameters:
/// - account: The active account.
/// - animated: Whether to animate the transition.
/// - attemptAutmaticBiometricUnlock: Whether to the processor should attempt a biometric unlock on appear.
/// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically.
/// - url: The URL for the single sign on web auth session.
/// - delegate: A `WebAuthnFlowDelegate` object that is notified when the WebAuthn flow succeeds or fails.
///
private func showVaultUnlock(
account: Account,
animated: Bool,
attemptAutmaticBiometricUnlock: Bool,
didSwitchAccountAutomatically: Bool
private func showWebAuthnSelfHosted(
authURL url: URL,
delegate: WebAuthnFlowDelegate?
) {
let processor = VaultUnlockProcessor(
appExtensionDelegate: appExtensionDelegate,
coordinator: asAnyCoordinator(),
services: services,
state: VaultUnlockState(account: account)
)
processor.shouldAttemptAutomaticBiometricUnlock = attemptAutmaticBiometricUnlock
let view = VaultUnlockView(store: Store(processor: processor))
stackNavigator?.replace(view, animated: animated)
if didSwitchAccountAutomatically {
processor.state.toast = Toast(text: Localizations.accountSwitchedAutomatically)
guard let delegate else { return }
let session = services.authService.webAuthenticationSession(
url: url
) { callbackURL, error in
if let error {
delegate.webAuthnErrored(error: error)
return
}
guard let callbackURL,
let components = URLComponents(
url: callbackURL,
resolvingAgainstBaseURL: false
),
let queryItems = components.queryItems,
let token = queryItems.first(where: { component in
component.name == "data"
})?.value else {
delegate.webAuthnErrored(error: WebAuthnError.unableToDecodeCredential)
return
}

delegate.webAuthnCompleted(token: token)
}

session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self
session.start()
}
}

Expand Down
Loading

0 comments on commit 6eee319

Please sign in to comment.