diff --git a/Authenticator.xcodeproj/project.pbxproj b/Authenticator.xcodeproj/project.pbxproj index 32e88d8..8d6a602 100644 --- a/Authenticator.xcodeproj/project.pbxproj +++ b/Authenticator.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 6FAFAD3E277FCD590041F854 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FAFAD3D277FCD590041F854 /* AppDelegate.swift */; }; + 6FAFAD91277FCD590041F854 /* DriveAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FAFAD90277FCD590041F854 /* DriveAuth.swift */; }; 6FAFAD41277FCD590041F854 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6FAFAD3F277FCD590041F854 /* Main.storyboard */; }; 6FAFAD53277FCD590041F854 /* Authenticator Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6FAFAD52277FCD590041F854 /* Authenticator Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6FAFAD5A277FCD590041F854 /* Main.html in Resources */ = {isa = PBXBuildFile; fileRef = 6FAFAD1C277FCD550041F854 /* Main.html */; }; @@ -62,21 +63,22 @@ 6FAFAD24277FCD590041F854 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; 6FAFAD3B277FCD590041F854 /* Authenticator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Authenticator.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6FAFAD3D277FCD590041F854 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 6FAFAD90277FCD590041F854 /* DriveAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveAuth.swift; sourceTree = ""; }; 6FAFAD40277FCD590041F854 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 6FAFAD42277FCD590041F854 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6FAFAD43277FCD590041F854 /* Authenticator.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Authenticator.entitlements; sourceTree = ""; }; 6FAFAD52277FCD590041F854 /* Authenticator Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Authenticator Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 6FAFAD57277FCD590041F854 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6FAFAD58277FCD590041F854 /* Authenticator.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Authenticator.entitlements; sourceTree = ""; }; - 6FAFAD78277FCD5B0041F854 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE; path = ./Authenticator/test/chrome/LICENSE; sourceTree = ""; }; - 6FAFAD79277FCD5B0041F854 /* css */ = {isa = PBXFileReference; lastKnownFileType = folder; name = css; path = ./Authenticator/test/chrome/css; sourceTree = ""; }; - 6FAFAD7A277FCD5B0041F854 /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dist; path = ./Authenticator/test/chrome/dist; sourceTree = ""; }; - 6FAFAD7B277FCD5B0041F854 /* images */ = {isa = PBXFileReference; lastKnownFileType = folder; name = images; path = ./Authenticator/test/chrome/images; sourceTree = ""; }; - 6FAFAD7C277FCD5B0041F854 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ./Authenticator/test/chrome/manifest.json; sourceTree = ""; }; - 6FAFAD7D277FCD5B0041F854 /* view */ = {isa = PBXFileReference; lastKnownFileType = folder; name = view; path = ./Authenticator/test/chrome/view; sourceTree = ""; }; - 6FAFAD7E277FCD5B0041F854 /* schema.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = schema.json; path = ./Authenticator/test/chrome/schema.json; sourceTree = ""; }; - 6FAFAD7F277FCD5B0041F854 /* _locales */ = {isa = PBXFileReference; lastKnownFileType = folder; name = _locales; path = ./Authenticator/test/chrome/_locales; sourceTree = ""; }; - 6FAFAD80277FCD5B0041F854 /* manifest-pwa.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = "manifest-pwa.json"; path = "./Authenticator/test/chrome/manifest-pwa.json"; sourceTree = ""; }; + 6FAFAD78277FCD5B0041F854 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE; path = ../authenticator/safari/LICENSE; sourceTree = ""; }; + 6FAFAD79277FCD5B0041F854 /* css */ = {isa = PBXFileReference; lastKnownFileType = folder; name = css; path = ../authenticator/safari/css; sourceTree = ""; }; + 6FAFAD7A277FCD5B0041F854 /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dist; path = ../authenticator/safari/dist; sourceTree = ""; }; + 6FAFAD7B277FCD5B0041F854 /* images */ = {isa = PBXFileReference; lastKnownFileType = folder; name = images; path = ../authenticator/safari/images; sourceTree = ""; }; + 6FAFAD7C277FCD5B0041F854 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../authenticator/safari/manifest.json; sourceTree = ""; }; + 6FAFAD7D277FCD5B0041F854 /* view */ = {isa = PBXFileReference; lastKnownFileType = folder; name = view; path = ../authenticator/safari/view; sourceTree = ""; }; + 6FAFAD7E277FCD5B0041F854 /* schema.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = schema.json; path = ../authenticator/safari/schema.json; sourceTree = ""; }; + 6FAFAD7F277FCD5B0041F854 /* _locales */ = {isa = PBXFileReference; lastKnownFileType = folder; name = _locales; path = ../authenticator/safari/_locales; sourceTree = ""; }; + 6FAFAD80277FCD5B0041F854 /* manifest-pwa.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = "manifest-pwa.json"; path = "../authenticator/safari/manifest-pwa.json"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -151,6 +153,7 @@ isa = PBXGroup; children = ( 6FAFAD3D277FCD590041F854 /* AppDelegate.swift */, + 6FAFAD90277FCD590041F854 /* DriveAuth.swift */, 6FAFAD3F277FCD590041F854 /* Main.storyboard */, 6FAFAD42277FCD590041F854 /* Info.plist */, 6FAFAD43277FCD590041F854 /* Authenticator.entitlements */, @@ -299,6 +302,7 @@ files = ( 6FAFAD62277FCD590041F854 /* ViewController.swift in Sources */, 6FAFAD3E277FCD590041F854 /* AppDelegate.swift in Sources */, + 6FAFAD91277FCD590041F854 /* DriveAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -467,7 +471,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-framework", @@ -498,7 +502,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-framework", @@ -532,9 +536,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( + "-framework", + AuthenticationServices, "-framework", SafariServices, "-framework", @@ -567,9 +573,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( + "-framework", + AuthenticationServices, "-framework", SafariServices, "-framework", diff --git a/Shared (Extension)/SafariWebExtensionHandler.swift b/Shared (Extension)/SafariWebExtensionHandler.swift index 52f10b9..25c0af7 100644 --- a/Shared (Extension)/SafariWebExtensionHandler.swift +++ b/Shared (Extension)/SafariWebExtensionHandler.swift @@ -1,26 +1,103 @@ -// -// SafariWebExtensionHandler.swift -// Shared (Extension) -// -// Created by penlv on 12/31/21. -// - -import SafariServices +import AppKit import os.log +import SafariServices let SFExtensionMessageKey = "message" -class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { +private let appGroupId = "group.com.yourCompany.Authenticator" +private let driveAuthNotification = Notification.Name("com.yourCompany.Authenticator.driveAuth") +class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { func beginRequest(with context: NSExtensionContext) { - let item = context.inputItems[0] as! NSExtensionItem - let message = item.userInfo?[SFExtensionMessageKey] - os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg) + DispatchQueue.main.async { + self.handle(context) + } + } - let response = NSExtensionItem() - response.userInfo = [ SFExtensionMessageKey: [ "Response to": message ] ] + private func handle(_ context: NSExtensionContext) { + let item = context.inputItems.first as? NSExtensionItem + let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] + os_log(.default, "Received native message") + + guard + message?["action"] as? String == "drive-auth", + let urlString = message?["url"] as? String, + let url = URL(string: urlString), + url.scheme == "https", + url.host == "accounts.google.com" + else { + complete(context, ["ok": false, "error": "unsupported"]) + return + } + + guard let defaults = UserDefaults(suiteName: appGroupId) else { + complete(context, ["ok": false, "error": "failed"]) + return + } + defaults.removeObject(forKey: "driveAuthCallback") + defaults.removeObject(forKey: "driveAuthError") + defaults.set(urlString, forKey: "driveAuthURL") + defaults.set("pending", forKey: "driveAuthState") + defaults.synchronize() + + openContainerApp() + DistributedNotificationCenter.default().post(name: driveAuthNotification, object: nil) + poll(context: context, defaults: defaults, deadline: Date().addingTimeInterval(180)) + } + + private func poll( + context: NSExtensionContext, + defaults: UserDefaults, + deadline: Date + ) { + let state = defaults.string(forKey: "driveAuthState") + if state == "done", let callback = defaults.string(forKey: "driveAuthCallback") { + clear(defaults) + complete(context, ["ok": true, "url": callback]) + return + } + if state == "error" { + let error = defaults.string(forKey: "driveAuthError") ?? "failed" + clear(defaults) + complete(context, ["ok": false, "error": error]) + return + } + if Date() > deadline { + clear(defaults) + complete(context, ["ok": false, "error": "timeout"]) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + self.poll(context: context, defaults: defaults, deadline: deadline) + } + } + + private func clear(_ defaults: UserDefaults) { + defaults.removeObject(forKey: "driveAuthCallback") + defaults.removeObject(forKey: "driveAuthError") + defaults.removeObject(forKey: "driveAuthURL") + defaults.set("idle", forKey: "driveAuthState") + defaults.synchronize() + } + + private func complete(_ context: NSExtensionContext, _ body: [String: Any]) { + let response = NSExtensionItem() + response.userInfo = [SFExtensionMessageKey: body] context.completeRequest(returningItems: [response], completionHandler: nil) } + private func openContainerApp() { + let appURL = Bundle.main.bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.openApplication(at: appURL, configuration: config) { _, error in + if error != nil { + os_log(.error, "Could not open Authen") + } + } + } } diff --git a/authenticator b/authenticator index 72430cf..dd6a4c9 160000 --- a/authenticator +++ b/authenticator @@ -1 +1 @@ -Subproject commit 72430cf1e27906312908d5a14aabad552e5a711c +Subproject commit dd6a4c9e696b9b3a4988e44d94e6a416034d75a1 diff --git a/macOS (App)/AppDelegate.swift b/macOS (App)/AppDelegate.swift index 600550f..fc221d4 100644 --- a/macOS (App)/AppDelegate.swift +++ b/macOS (App)/AppDelegate.swift @@ -11,7 +11,14 @@ import Cocoa class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - // Override point for customization after application launch. + DriveAuth.shared.startObserving() + DispatchQueue.main.async { + DriveAuth.shared.startIfPending() + } + } + + func applicationDidBecomeActive(_ notification: Notification) { + DriveAuth.shared.startIfPending() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/macOS (App)/DriveAuth.swift b/macOS (App)/DriveAuth.swift new file mode 100644 index 0000000..2c02324 --- /dev/null +++ b/macOS (App)/DriveAuth.swift @@ -0,0 +1,78 @@ +import AuthenticationServices +import Cocoa + +private let appGroupId = "group.com.yourCompany.Authenticator" +private let driveAuthNotification = Notification.Name("com.yourCompany.Authenticator.driveAuth") +private let driveRedirect = URL(string: "https://authenticator.cc/oauth")! + +final class DriveAuth: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = DriveAuth() + + private var session: ASWebAuthenticationSession? + + func startObserving() { + DistributedNotificationCenter.default().addObserver( + forName: driveAuthNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.startIfPending() + } + } + + func startIfPending() { + guard session == nil else { + return + } + guard let defaults = UserDefaults(suiteName: appGroupId) else { + return + } + guard defaults.string(forKey: "driveAuthState") == "pending", + let urlString = defaults.string(forKey: "driveAuthURL"), + let url = URL(string: urlString), + url.scheme == "https", + url.host == "accounts.google.com" + else { + return + } + + let session = ASWebAuthenticationSession( + url: url, + callback: .https(host: driveRedirect.host ?? "authenticator.cc", path: driveRedirect.path), + completionHandler: { [weak self] callbackURL, error in + self?.finish(callbackURL: callbackURL, error: error) + } + ) + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + if !session.start() { + finish(callbackURL: nil, error: nil) + } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return NSApplication.shared.keyWindow + ?? NSApplication.shared.windows.first + ?? ASPresentationAnchor() + } + + private func finish(callbackURL: URL?, error: Error?) { + let defaults = UserDefaults(suiteName: appGroupId) + if let callbackURL = callbackURL, + callbackURL.scheme == "https", + callbackURL.host == driveRedirect.host, + callbackURL.path == driveRedirect.path + { + defaults?.set(callbackURL.absoluteString, forKey: "driveAuthCallback") + defaults?.set("done", forKey: "driveAuthState") + } else { + let cancelled = (error as NSError?)?.code == ASWebAuthenticationSessionError.canceledLogin.rawValue + defaults?.set(cancelled ? "cancelled" : "failed", forKey: "driveAuthError") + defaults?.set("error", forKey: "driveAuthState") + } + defaults?.removeObject(forKey: "driveAuthURL") + defaults?.synchronize() + session = nil + } +} diff --git a/readme.md b/readme.md index ddb3df7..c57dd05 100644 --- a/readme.md +++ b/readme.md @@ -1,14 +1,38 @@ # Authen -Safari extension distro for [Authenticator](https://github.com/Authenticator-Extension/Authenticator) +Safari extension distro for [Authenticator](https://github.com/Authenticator-Extension/Authenticator) Download on the App Store ![2560x1600bb](https://user-images.githubusercontent.com/876920/176361939-a359dd46-254a-4784-86bf-19f9c999f5aa.png) -## How to contribute +## Build -* Clone the project -* `git submodule update --remote` to update the sub module for [Authenticator](https://github.com/Authenticator-Extension/Authenticator) -* `npm ci && npm run chrome` -* Launch the project and run \ No newline at end of file +The extension lives in the `authenticator` submodule. + +``` +git submodule update --init +./scripts/build-safari.sh +``` + +Open `Authenticator.xcodeproj` and run the macOS app. The script installs the submodule dependencies with Puppeteer's Chromium download skipped, and it sets `NODE_OPTIONS=--openssl-legacy-provider` so webpack 4 runs on current Node. Xcode packages `authenticator/safari`. + +The bundle ids stay `com.yourCompany.Authenticator` and `com.yourCompany.Authenticator.Extension`. `DEVELOPMENT_TEAM` is empty. Signing and the App Store upload stay with the maintainers. The deployment target is macOS 14.4, which is what `ASWebAuthenticationSession` needs for an https callback. + +Google Drive sign-in uses the client id already in the Chrome manifest. The client secret in `authenticator/src/models/credentials.ts` stays empty in git. The release script fills it before a production build. The redirect is `https://authenticator.cc/oauth`. + +## Drive backups + +A backup is a dated JSON file in a Drive folder named Authenticator Backups. The scope is `drive.file`, so the app only sees files it created. There is no download or merge. + +With a passphrase, the file is encrypted. That encryption is only as strong as the passphrase. With encryption turned off, or with no passphrase, the file is the raw secrets. The Drive page already warns about that. Dropbox and OneDrive stay in the code and stay hidden on Safari. + +## Dependencies + +`crypto-js` is 4.2.0. That clears the production PBKDF2 advisory `npm audit` reported for 4.0.0. + +These stay unfixed because the fix is a new major version, and this tree stays on Vue 2 and webpack 4: + +- `uuid` 3.x. The patched release is 11. +- Vue 2. The ReDoS advisory is fixed in Vue 3, and Vue 2 is end of life. +- High and critical findings in the dev tree (webpack 4, eslint 6, and their dependencies). `npm audit fix --force` would leave Vue 2 and webpack 4. diff --git a/scripts/build-safari.sh b/scripts/build-safari.sh new file mode 100755 index 0000000..b627220 --- /dev/null +++ b/scripts/build-safari.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/authenticator" + +case ":${NODE_OPTIONS:-}:" in + *--openssl-legacy-provider*) ;; + *) export NODE_OPTIONS="${NODE_OPTIONS:-} --openssl-legacy-provider" ;; +esac + +if [[ ! -d node_modules ]]; then + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm ci +fi + +npm run safari