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