diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 8e8a9fd54..a815c02f9 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -21,6 +21,9 @@ struct FirezoneApp: App { #if os(iOS) WindowGroup { AppView(model: model) + .onOpenURL { url in + model.appStore?.continueSignIn(appOpenedWithURL: url) + } } #else WindowGroup("Settings") { @@ -45,6 +48,12 @@ struct FirezoneApp: App { window.close() } + func application(_: NSApplication, open urls: [URL]) { + if let openedWithURL = urls.first { + menuBar.appStore?.continueSignIn(appOpenedWithURL: openedWithURL) + } + } + func applicationWillTerminate(_: Notification) {} } #endif diff --git a/swift/apple/Firezone/Info.plist b/swift/apple/Firezone/Info.plist index a0133aefd..827b12f46 100644 --- a/swift/apple/Firezone/Info.plist +++ b/swift/apple/Firezone/Info.plist @@ -12,6 +12,7 @@ CFBundleURLSchemes firezone + firezone-fd0020211111 diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/AuthClient/AuthClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/AuthClient/AuthClient.swift index 572a44b81..c16803786 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/AuthClient/AuthClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/AuthClient/AuthClient.swift @@ -10,12 +10,17 @@ import Foundation enum AuthClientError: Error { case invalidCallbackURL(URL?) + case openedWithURLWithBadScheme(URL) + case missingCSRFToken(URL) + case mismatchInCSRFToken(URL, String) case authResponseError(Error) case sessionFailure(Error) + case noAuthSessionInProgress } struct AuthClient: Sendable { var signIn: @Sendable (URL) async throws -> AuthResponse + var continueSignIn: @Sendable (URL) throws -> AuthResponse } extension AuthClient: DependencyKey { @@ -24,6 +29,9 @@ extension AuthClient: DependencyKey { return AuthClient( signIn: { host in try await session.signIn(host) + }, + continueSignIn: { callbackURL in + try session.continueSignIn(appOpenedWithURL: callbackURL) } ) } @@ -39,16 +47,23 @@ extension DependencyValues { private final class WebAuthenticationSession: NSObject, ASWebAuthenticationPresentationContextProviding { + var currentAuthSession: (webAuthSession: ASWebAuthenticationSession, host: URL, csrfToken: String)? @MainActor func signIn(_ host: URL) async throws -> AuthResponse { try await withCheckedThrowingContinuation { continuation in + let csrfToken = UUID().uuidString let callbackURLScheme = "firezone" let session = ASWebAuthenticationSession( url: host.appendingPathComponent("sign_in") - + .appendingQueryItem(URLQueryItem(name: "client_csrf_token", value: csrfToken)) .appendingQueryItem(URLQueryItem(name: "client_platform", value: "apple")), callbackURLScheme: callbackURLScheme - ) { callbackURL, error in + ) { [weak self] callbackURL, error in + + guard let self = self else { return } + + self.currentAuthSession = nil + if let error { continuation.resume(throwing: AuthClientError.sessionFailure(error)) return @@ -59,36 +74,16 @@ private final class WebAuthenticationSession: NSObject, return } - guard - let token = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "client_auth_token" })? - .value - else { - continuation.resume(throwing: AuthClientError.invalidCallbackURL(callbackURL)) - return - } - - guard - let actorName = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "actor_name" })? - .value? - .removingPercentEncoding? - .replacingOccurrences(of: "+", with: " ") - else { - continuation.resume(throwing: AuthClientError.invalidCallbackURL(callbackURL)) - return - } - do { - let authResponse = try AuthResponse(portalURL: host, token: token, actorName: actorName) + let authResponse = try self.readAuthCallback(portalURL: host, callbackURL: callbackURL, csrfToken: nil) continuation.resume(returning: authResponse) } catch { - continuation.resume(throwing: AuthClientError.authResponseError(error)) + continuation.resume(throwing: error) } } + self.currentAuthSession = (session, host, csrfToken) + session.presentationContextProvider = self // We want to load any SSO cookies that the user may have set in their default browser @@ -101,6 +96,57 @@ private final class WebAuthenticationSession: NSObject, func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { ASPresentationAnchor() } + + func continueSignIn(appOpenedWithURL: URL) throws -> AuthResponse { + guard let currentAuthSession = self.currentAuthSession else { + throw AuthClientError.noAuthSessionInProgress + } + guard appOpenedWithURL.scheme == "firezone-fd0020211111" else { + throw AuthClientError.openedWithURLWithBadScheme(appOpenedWithURL) + } + currentAuthSession.webAuthSession.cancel() + self.currentAuthSession = nil + return try readAuthCallback(portalURL: currentAuthSession.host, callbackURL: appOpenedWithURL, csrfToken: currentAuthSession.csrfToken) + } + + private func readAuthCallback(portalURL: URL, callbackURL: URL, csrfToken: String?) throws -> AuthResponse { + guard + let token = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "client_auth_token" })? + .value + else { + throw AuthClientError.invalidCallbackURL(callbackURL) + } + + guard + let actorName = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "actor_name" })? + .value? + .removingPercentEncoding? + .replacingOccurrences(of: "+", with: " ") + else { + throw AuthClientError.invalidCallbackURL(callbackURL) + } + + if let csrfToken = csrfToken { + guard + let callbackCSRFToken = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "client_csrf_token" })? + .value + else { + throw AuthClientError.missingCSRFToken(callbackURL) + } + + guard callbackCSRFToken == csrfToken else { + throw AuthClientError.mismatchInCSRFToken(callbackURL, csrfToken) + } + } + + return AuthResponse(portalURL: portalURL, token: token, actorName: actorName) + } } extension URL { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift index c9d408f69..1e9bc5662 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift @@ -14,6 +14,10 @@ import SwiftUINavigation public final class AppViewModel: ObservableObject { @Published var welcomeViewModel: WelcomeViewModel? + public var appStore: AppStore? { + welcomeViewModel?.appStore + } + public init() { Task { let tunnel = try await TunnelStore.loadOrCreate() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift index 67456d138..92112be8d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift @@ -43,7 +43,7 @@ final class WelcomeViewModel: ObservableObject { } } - private let appStore: AppStore + public let appStore: AppStore init(appStore: AppStore) { self.appStore = appStore diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift index 576d3613e..9effea3c1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthResponse.swift @@ -16,7 +16,7 @@ struct AuthResponse { // The opaque auth token let token: String - init(portalURL: URL, token: String, actorName: String?) throws { + init(portalURL: URL, token: String, actorName: String?) { self.portalURL = portalURL self.actorName = actorName self.token = token @@ -26,14 +26,14 @@ struct AuthResponse { #if DEBUG extension AuthResponse { static let invalid = - try! AuthResponse( + AuthResponse( portalURL: URL(string: "http://localhost:4568")!, token: "", actorName: nil ) static let valid = - try! AuthResponse( + AuthResponse( portalURL: URL(string: "http://localhost:4568")!, token: "b1zwwwAdf=", actorName: "foobar" diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift index f9740df70..9650bf245 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift @@ -9,7 +9,7 @@ import Dependencies import OSLog @MainActor -final class AppStore: ObservableObject { +public final class AppStore: ObservableObject { private let logger = Logger.make(for: AppStore.self) @Dependency(\.authStore) var auth @@ -58,4 +58,12 @@ final class AppStore: ObservableObject { tunnel.stop() auth.signOut() } + + public func continueSignIn(appOpenedWithURL: URL) { + do { + try auth.continueSignIn(appOpenedWithURL: appOpenedWithURL) + } catch { + logger.error("Error continuing auth: \(String(describing: error))") + } + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift index 0a34d6a20..7f6c4c510 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift @@ -84,6 +84,11 @@ final class AuthStore: ObservableObject { self.authResponse = authResponse } + func continueSignIn(appOpenedWithURL: URL) throws { + let authResponse = try auth.continueSignIn(appOpenedWithURL) + self.authResponse = authResponse + } + func signIn() async throws { logger.trace("\(#function)") diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index b41d8b876..735f06897 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -17,7 +17,7 @@ public final class MenuBar: NSObject { let logger = Logger.make(for: MenuBar.self) @Dependency(\.mainQueue) private var mainQueue - private var appStore: AppStore? { + public private(set) var appStore: AppStore? { didSet { setupObservers() } diff --git a/swift/apple/PortalMock/server.rb b/swift/apple/PortalMock/server.rb index c323541d5..eef0c4b53 100755 --- a/swift/apple/PortalMock/server.rb +++ b/swift/apple/PortalMock/server.rb @@ -7,7 +7,8 @@ set :bind, '0.0.0.0' set :port, 4568 get '/:slug/sign_in' do - ERB.new("

Auth page

Proceed") + client_csrf_token = params['client_csrf_token'] + ERB.new("

Auth page

Proceed

Magic Link (Copy link and open in External Browser)

") .result(binding) end @@ -15,3 +16,10 @@ get '/redirect' do client_auth_token = File.read('./data/client_auth_token').strip redirect "firezone://handle_client_auth_callback?client_auth_token=#{client_auth_token}&actor_name=Foo+Bar" end + +get '/:client_csrf_token/magiclink' do + client_csrf_token = params[:client_csrf_token] + client_auth_token = File.read('./data/client_auth_token').strip + ERB.new("

Magic Link Page

Open Firezone App

") + .result(binding) +end