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:
Roopesh Chander
2023-08-15 01:36:01 +05:30
committed by GitHub
parent 0b228934d6
commit 6ed762d5b4
10 changed files with 114 additions and 33 deletions

View File

@@ -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

View File

@@ -12,6 +12,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>firezone</string>
<string>firezone-fd0020211111</string>
</array>
</dict>
</array>

View File

@@ -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 {

View File

@@ -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()

View File

@@ -43,7 +43,7 @@ final class WelcomeViewModel: ObservableObject {
}
}
private let appStore: AppStore
public let appStore: AppStore
init(appStore: AppStore) {
self.appStore = appStore

View File

@@ -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"

View File

@@ -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))")
}
}
}

View File

@@ -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)")

View File

@@ -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()
}

View File

@@ -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