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
89 changes: 63 additions & 26 deletions platforms/ios/runner/lib/network/AppAPIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,19 @@ 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 credentials = task.request.value(forHTTPHeaderField: "X-Acode-Credentials") == "include"
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.onRedirect = { [auth] response, next in
Self.storeCookies(from: response, credentials: credentials)
try Self.applyCookies(to: &next, credentials: credentials, auth: auth)
}
transport.onResponse = { response in
Self.storeCookies(from: response, credentials: credentials)
if redirects == "error", (300..<400).contains(response.statusCode) { throw URLError(.redirectToNonExistentLocation) }
var headers: [String: String] = [:]
for (key, value) in response.allHeaderFields {
Expand All @@ -66,4 +51,56 @@ 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"
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")
try Self.applyCookies(to: &request, credentials: credentials, auth: auth)
return request
}

static func storeCookies(from response: HTTPURLResponse, credentials: Bool) {
guard credentials, let url = response.url, AppAuthentication.accepts(url),
let header = response.value(forHTTPHeaderField: "Set-Cookie") else { return }
let storage = HTTPCookieStorage.shared
guard storage.cookieAcceptPolicy != .never else { return }
for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: url) where cookie.name != "token" {
let domain = cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
guard AppAuthentication.hosts.contains(domain), url.host == domain || url.host!.hasSuffix("." + domain) else { continue }
// Foundation requires the stored HttpOnly object when replacing or deleting it.
for existing in storage.cookies ?? [] where existing.name == cookie.name && existing.domain == cookie.domain && existing.path == cookie.path {
storage.deleteCookie(existing)
}
if let expiry = cookie.expiresDate, expiry <= Date() { continue }
storage.setCookies([cookie], for: url, mainDocumentURL: url)
}
}

private static func applyCookies(to request: inout URLRequest, credentials: Bool, auth: AppAuthentication) throws {
guard let url = request.url else { throw URLError(.badURL) }
let trusted = AppAuthentication.accepts(url)
request.httpShouldHandleCookies = credentials && !trusted
request.setValue(nil, forHTTPHeaderField: "Cookie")
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")
}
}
}
7 changes: 6 additions & 1 deletion platforms/ios/runner/lib/network/HTTPTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ final class HTTPTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable
var onData: (Data) throws -> Void = { _ in }
var onComplete: (Error?) -> Void = { _ in }
var allowRedirect: (URL) -> Bool = { _ in true }
var onRedirect: (HTTPURLResponse, inout URLRequest) throws -> Void = { _, _ in }
private let request: URLRequest
private let followRedirects: Bool
private let connectTimeout: TimeInterval
Expand Down Expand Up @@ -88,6 +89,10 @@ final class HTTPTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable
completionHandler(nil); return
}
var redirect = request
if followRedirects {
do { try onRedirect(response, &redirect) }
catch { failure = error; completionHandler(nil); return }
}
if request.url?.host != task.currentRequest?.url?.host || request.url?.scheme != task.currentRequest?.url?.scheme || request.url?.port != task.currentRequest?.url?.port {
for header in ["Authorization", "Cookie", "Proxy-Authorization"] { redirect.setValue(nil, forHTTPHeaderField: header) }
}
Expand Down Expand Up @@ -122,6 +127,6 @@ final class HTTPTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable
onComplete(error)
session?.finishTasksAndInvalidate()
session = nil; task = nil
onResponse = { _ in }; onData = { _ in }; onComplete = { _ in }
onResponse = { _ in }; onData = { _ in }; onComplete = { _ in }; onRedirect = { _, _ in }
}
}
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"
}
}
Loading
Loading