mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
Apple: Add support for magic links (#1909)
Addresses #1899. Tested only with PortalMock. Works with Safari. Not tested yet with non-Safari browsers as default in macOS (if required, will address that separately). `client_csrf_token` is always passed. It's verified only if the sign in happens with the external open-app-with-URL scenario. It's not checked if the user logs in inside of the `ASWebAuthenticationSession` webpage itself.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>firezone</string>
|
||||
<string>firezone-fd0020211111</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -43,7 +43,7 @@ final class WelcomeViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private let appStore: AppStore
|
||||
public let appStore: AppStore
|
||||
|
||||
init(appStore: AppStore) {
|
||||
self.appStore = appStore
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ set :bind, '0.0.0.0'
|
||||
set :port, 4568
|
||||
|
||||
get '/:slug/sign_in' do
|
||||
ERB.new("<h1>Auth page</h1><a href=\"/redirect\">Proceed</a>")
|
||||
client_csrf_token = params['client_csrf_token']
|
||||
ERB.new("<h1>Auth page</h1><p><a href=\"/redirect\">Proceed</a></p><p><a href=\"/#{client_csrf_token}/magiclink\">Magic Link (Copy link and open in External Browser)</a></p>")
|
||||
.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("<h1>Magic Link Page</h1><p><a href=\"firezone-fd0020211111://handle_client_auth_callback?client_auth_token=#{client_auth_token}&actor_name=Foo+Bar&client_csrf_token=#{client_csrf_token}\">Open Firezone App</a></p></p>")
|
||||
.result(binding)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user