Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function patchProjectFile(pkg) {
function patchDisplayName(pkg) {
patchFile(infoPlistFile, (content) =>
content.replace(
/(<key>CFBundleDisplayName<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/,
/(<key>CFBundle(?:Display)?Name<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/g,
`$1${pkg.displayName}$2`,
),
);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "acode-app",
"displayName": "Acode",
"version": "1.13.6",
"versionCode": 1016,
"versionCode": 1018,
"androidPackageId": "com.foxdebug.acode",
"appleAppId": "app.acode",
"description": "Powerful code editor and terminal for IOS and Android",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ private void startLogin(JSONObject options, Callback callbackContext) {
.buildUpon()
.appendEncodedPath("login")
.appendQueryParameter("redirect", "app")
.appendQueryParameter("src", "android")
.appendQueryParameter("androidPackage", host.getContext().getPackageName())
.appendQueryParameter("authFlow", "app-code")
.appendQueryParameter("state", state)
.appendQueryParameter("challenge", challenge)
Expand Down
4 changes: 2 additions & 2 deletions platforms/ios/runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1016;
CURRENT_PROJECT_VERSION = 1018;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
Expand Down Expand Up @@ -443,7 +443,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1016;
CURRENT_PROJECT_VERSION = 1018;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand Down
2 changes: 2 additions & 0 deletions platforms/ios/runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Acode</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
Expand Down
60 changes: 34 additions & 26 deletions platforms/ios/runner/lib/network/AppAPIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,12 @@ final class AppAPIHandler {
func start(_ task: WKURLSchemeTask) {
let id = ObjectIdentifier(task)
do {
guard let source = task.request.url, source.scheme == "acode", source.host == "localhost", source.path.hasPrefix("/__api__/") else { throw URLError(.badURL) }
var destination = URLComponents(url: origin, resolvingAgainstBaseURL: false)!
let incoming = URLComponents(url: source, resolvingAgainstBaseURL: false)!
destination.percentEncodedPath = "/api/" + incoming.percentEncodedPath.dropFirst("/__api__/".count)
destination.percentEncodedQuery = incoming.percentEncodedQuery
guard let url = destination.url else { throw URLError(.badURL) }
var request = task.request
request.url = url
request.mainDocumentURL = origin
let credentials = request.value(forHTTPHeaderField: "X-Acode-Credentials") == "include"
let redirects = request.value(forHTTPHeaderField: "X-Acode-Redirect") ?? "follow"
request.httpShouldHandleCookies = credentials
for key in (request.allHTTPHeaderFields ?? [:]).keys where ["host", "origin", "referer", "cookie", "content-length"].contains(key.lowercased()) || key.lowercased().hasPrefix("x-acode-") || key.lowercased().hasPrefix("sec-") {
request.setValue(nil, forHTTPHeaderField: key)
}
request.setValue("https://localhost", forHTTPHeaderField: "Origin")
request.setValue("https://localhost/", forHTTPHeaderField: "Referer")
if credentials, AppAuthentication.accepts(url) {
let token = try auth.secrets.get("auth_token")
if !token.isEmpty {
var cookies = (HTTPCookieStorage.shared.cookies(for: url) ?? []).filter { $0.name != "token" }
if let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: url.host!, .path: "/", .secure: "TRUE"]) { cookies.append(cookie) }
request.setValue(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"], forHTTPHeaderField: "Cookie")
}
}
let request = try request(for: task.request)
let source = task.request.url!
let url = request.url!
let redirects = task.request.value(forHTTPHeaderField: "X-Acode-Redirect") ?? "follow"
let transport = HTTPTransport(request: request, followRedirects: redirects == "follow", connectTimeout: 30, readTimeout: 60, cookieStorage: .shared)
transport.allowRedirect = { [origin] next in next.scheme == origin.scheme && next.host == origin.host && next.port == origin.port && next.user == nil && next.password == nil }
transport.allowRedirect = { next in next.scheme == url.scheme && next.host == url.host && next.port == url.port && next.user == nil && next.password == nil }
transport.onResponse = { response in
if redirects == "error", (300..<400).contains(response.statusCode) { throw URLError(.redirectToNonExistentLocation) }
var headers: [String: String] = [:]
Expand All @@ -66,4 +45,33 @@ final class AppAPIHandler {
}

func stop(_ task: WKURLSchemeTask) { requests.removeValue(forKey: ObjectIdentifier(task))?.cancel() }

func request(for incomingRequest: URLRequest) throws -> URLRequest {
guard let source = incomingRequest.url, source.scheme == "acode", source.host == "localhost", source.path.hasPrefix("/__api__/") else { throw URLError(.badURL) }
let origin = try incomingRequest.value(forHTTPHeaderField: "X-Acode-API-Origin").map(AppAuthentication.baseURL) ?? self.origin
var destination = URLComponents(url: origin, resolvingAgainstBaseURL: false)!
let incoming = URLComponents(url: source, resolvingAgainstBaseURL: false)!
destination.percentEncodedPath = "/api/" + incoming.percentEncodedPath.dropFirst("/__api__/".count)
destination.percentEncodedQuery = incoming.percentEncodedQuery
guard let url = destination.url else { throw URLError(.badURL) }
var request = incomingRequest
request.url = url
request.mainDocumentURL = origin
let credentials = request.value(forHTTPHeaderField: "X-Acode-Credentials") == "include"
let trusted = AppAuthentication.accepts(url)
request.httpShouldHandleCookies = credentials && !trusted
for key in (request.allHTTPHeaderFields ?? [:]).keys where ["host", "origin", "referer", "cookie", "content-length"].contains(key.lowercased()) || key.lowercased().hasPrefix("x-acode-") || key.lowercased().hasPrefix("sec-") {
request.setValue(nil, forHTTPHeaderField: key)
}
request.setValue("https://localhost", forHTTPHeaderField: "Origin")
request.setValue("https://localhost/", forHTTPHeaderField: "Referer")
if credentials, trusted {
// A parent-domain cookie must not override the token for the selected environment.
var cookies = (HTTPCookieStorage.shared.cookies(for: url) ?? []).filter { $0.name != "token" }
let token = try auth.token(for: url)
if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: url.host!, .path: "/", .secure: "TRUE"]) { cookies.append(cookie) }
request.setValue(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"], forHTTPHeaderField: "Cookie")
}
return request
}
}
69 changes: 52 additions & 17 deletions platforms/ios/runner/lib/security/AppAuthentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,24 @@ import WebKit

struct AppAuthentication {
static let origin = "https://acode.app"
static let hosts = ["acode.app", "dev.acode.app"]
let secrets: SecretStore

init(namespace: String = "acode_auth_secure") { secrets = SecretStore(namespace: namespace) }

static func accepts(_ url: URL) -> Bool {
url.scheme == "https" && url.host == "acode.app" && (url.port == nil || url.port == 443) && url.user == nil && url.password == nil
url.scheme == "https" && hosts.contains(url.host ?? "") && (url.port == nil || url.port == 443) && url.user == nil && url.password == nil
}

func begin(baseURL: String, version: Int) throws -> URL {
guard let base = URL(string: baseURL), Self.accepts(base), ["", "/"].contains(base.path), base.query == nil, base.fragment == nil else {
throw SecretFailure("Unsupported sign-in URL")
}
let base = try Self.baseURL(baseURL)
let state = try SecureToken.generate(bytes: 24)
let verifier = try SecureToken.generate()
let challenge = SHA256.hash(data: Data(verifier.utf8)).map { String(format: "%02x", $0) }.joined()
let pending = try JSONSerialization.data(withJSONObject: ["state": state, "verifier": verifier])
let pending = try JSONSerialization.data(withJSONObject: ["state": state, "verifier": verifier, "origin": base.absoluteString])
try secrets.set("pending_login", value: String(decoding: pending, as: UTF8.self))
var url = URLComponents(string: Self.origin + "/login")!
url.queryItems = ["redirect": "app", "authFlow": "app-code", "state": state, "challenge": challenge, "appVersionCode": String(version)].map { URLQueryItem(name: $0.key, value: $0.value) }
var url = URLComponents(url: base.appendingPathComponent("login"), resolvingAgainstBaseURL: false)!
url.queryItems = ["redirect": "app", "authFlow": "app-code", "state": state, "challenge": challenge, "appVersionCode": String(version), "src": "ios"].map { URLQueryItem(name: $0.key, value: $0.value) }
return url.url!
}

Expand All @@ -36,37 +35,73 @@ struct AppAuthentication {
SecureToken.matches(state, pending["state"]), let verifier = pending["verifier"], !verifier.isEmpty else {
throw SecretFailure("Invalid sign-in callback")
}
let base = try Self.baseURL(pending["origin"] ?? Self.origin)
try cancel()
var request = URLRequest(url: URL(string: Self.origin + "/api/user/app-token/exchange")!)
var request = URLRequest(url: base.appendingPathComponent("api/user/app-token/exchange"))
request.httpMethod = "POST"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["code": code, "state": state, "verifier": verifier])
return request
}

func save(_ token: String) throws {
func token(for url: URL) throws -> String { try secrets.get(Self.tokenKey(for: url)) }

static func accountURL(baseURL: String) throws -> URL {
let base = try Self.baseURL(baseURL)
var url = URLComponents(url: base.appendingPathComponent("profile/edit"), resolvingAgainstBaseURL: false)!
url.queryItems = [URLQueryItem(name: "src", value: "ios")]
return url.url!
}

func save(_ token: String, for url: URL = URL(string: Self.origin)!) throws {
guard !token.isEmpty, token.utf8.allSatisfy({ $0 == 0x21 || (0x23...0x2b).contains($0) || (0x2d...0x3a).contains($0) || (0x3c...0x5b).contains($0) || (0x5d...0x7e).contains($0) }) else {
throw SecretFailure("Invalid sign-in token")
}
try secrets.set("auth_token", value: token)
try secrets.set(Self.tokenKey(for: url), value: token)
}

func cancel() throws { try secrets.remove("pending_login") }
func logout() throws { try cancel(); try secrets.remove("auth_token") }
func logout() throws { try cancel(); try secrets.remove("auth_token"); try secrets.remove("auth_token_dev") }

@MainActor
func synchronizeCookies(_ store: WKHTTPCookieStore) async throws {
for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && ["acode.app", ".acode.app", "dev.acode.app"].contains(cookie.domain) {
for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && Self.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
for cookie in await store.allCookies() where cookie.name == "token" && ["acode.app", ".acode.app", "dev.acode.app"].contains(cookie.domain) {
for cookie in await store.allCookies() where cookie.name == "token" && Self.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) {
await store.deleteCookie(cookie)
}
let token = try secrets.get("auth_token")
if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: ".acode.app", .path: "/", .secure: "TRUE", .expires: Date.distantFuture, HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "None"]) {
await store.setCookie(cookie)
HTTPCookieStorage.shared.setCookie(cookie)
for host in Self.hosts {
let token = try token(for: URL(string: "https://" + host)!)
if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: host, .path: "/", .secure: "TRUE", .expires: Date.distantFuture, HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "None"]) {
await store.setCookie(cookie)
HTTPCookieStorage.shared.setCookie(cookie)
}
}
}

static func baseURL(_ value: String) throws -> URL {
guard let url = URL(string: value), accepts(url), ["", "/"].contains(url.path), url.query == nil, url.fragment == nil else {
throw SecretFailure("Unsupported sign-in URL")
}
return URL(string: "https://" + url.host!)!
}

static func exchangeFailure(_ response: HTTPURLResponse) -> SecretFailure {
if response.statusCode == 429 {
if let seconds = Int(response.value(forHTTPHeaderField: "Retry-After") ?? ""), seconds > 0 {
let minutes = (min(seconds, 86400) + 59) / 60
return SecretFailure("Too many sign-in attempts. Please wait \(minutes) minute(s) and try again.")
}
return SecretFailure("Too many sign-in attempts. Please wait before trying again.")
}
if [400, 401].contains(response.statusCode) { return SecretFailure("Sign-in expired. Please try again.") }
return SecretFailure("The sign-in service is unavailable. Please try again later.")
}

private static func tokenKey(for url: URL) throws -> String {
guard accepts(url) else { throw SecretFailure("Unsupported sign-in URL") }
return url.host == "acode.app" ? "auth_token" : "auth_token_dev"
}
}
30 changes: 22 additions & 8 deletions platforms/ios/runner/lib/security/AuthenticatorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,35 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation

func exec(action: String, args: [Any], callback: Callback) {
switch action {
case "login":
case "login", "openAccount":
cancel()
do {
let options = args[safe: 0] as? [String: Any] ?? [:]
let url = try auth.begin(baseURL: options["baseUrl"] as? String ?? AppAuthentication.origin, version: options["appVersionCode"] as? Int ?? 0)
let account = action == "openAccount"
let baseURL = options["baseUrl"] as? String ?? AppAuthentication.origin
let url = try account ? AppAuthentication.accountURL(baseURL: baseURL) : auth.begin(baseURL: baseURL, version: options["appVersionCode"] as? Int ?? 0)
self.callback = callback
let id = operation
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "acode") { [weak self] url, error in
Task { @MainActor in
guard let self, self.operation == id else { return }
if let url { self.receive(url) }
if account {
if let error, (error as? ASWebAuthenticationSessionError)?.code != .canceledLogin {
self.fail(error.localizedDescription); return
}
self.callback = nil; self.session = nil
callback.success()
}
else if let url { self.receive(url) }
else if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
self.fail("Sign-in was cancelled. You can try again.")
}
else { self.fail(error?.localizedDescription ?? "Sign-in cancelled") }
}
}
session.presentationContextProvider = self
self.session = session
if !session.start() { fail("Unable to open sign-in") }
if !session.start() { fail(account ? "Unable to open account settings" : "Unable to open sign-in") }
} catch { self.callback = nil; callback.error(error.localizedDescription) }
case "saveToken", "logout":
cancel()
Expand All @@ -76,7 +88,7 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation
let transport = HTTPTransport(request: request, followRedirects: false, connectTimeout: 15, readTimeout: 30)
var data = Data()
transport.onResponse = { response in
guard (200..<300).contains(response.statusCode) else { throw SecretFailure("Unable to complete sign-in") }
guard (200..<300).contains(response.statusCode) else { throw AppAuthentication.exchangeFailure(response) }
}
transport.onData = { bytes in
guard data.count + bytes.count <= 65536 else { throw SecretFailure("Invalid sign-in response") }
Expand All @@ -86,15 +98,17 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation
Task { @MainActor in
guard let self, self.operation == id else { return }
do {
if error != nil { throw SecretFailure("Unable to complete sign-in") }
if let error { throw error }
let response = try JSONSerialization.jsonObject(with: data) as? [String: Any]
try self.auth.save(response?["token"] as? String ?? "")
try self.auth.save(response?["token"] as? String ?? "", for: request.url!)
if let store = self.bridge?.webView?.configuration.websiteDataStore.httpCookieStore { try await self.auth.synchronizeCookies(store) }
guard self.operation == id else { return }
let callback = self.callback
self.callback = nil; self.exchange = nil; self.session = nil
callback?.success()
} catch { self.fail("Unable to complete sign-in") }
} catch {
self.fail((error as? SecretFailure)?.message ?? "Sign-in could not be completed. Please check your connection and try again.")
}
}
}
exchange = transport
Expand Down
Loading
Loading