diff --git a/dev/sync.js b/dev/sync.js index c36fe79..c7fc7e7 100644 --- a/dev/sync.js +++ b/dev/sync.js @@ -57,7 +57,7 @@ function patchProjectFile(pkg) { function patchDisplayName(pkg) { patchFile(infoPlistFile, (content) => content.replace( - /(CFBundleDisplayName<\/key>\s*\n\s*)[^<]*(<\/string>)/, + /(CFBundle(?:Display)?Name<\/key>\s*\n\s*)[^<]*(<\/string>)/g, `$1${pkg.displayName}$2`, ), ); diff --git a/package.json b/package.json index 66632b2..5ba9f57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/platforms/android/app/src/main/java/com/foxdebug/acode/auth/Authenticator.java b/platforms/android/app/src/main/java/com/foxdebug/acode/auth/Authenticator.java index 1d79697..ba5d00a 100644 --- a/platforms/android/app/src/main/java/com/foxdebug/acode/auth/Authenticator.java +++ b/platforms/android/app/src/main/java/com/foxdebug/acode/auth/Authenticator.java @@ -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) diff --git a/platforms/ios/runner.xcodeproj/project.pbxproj b/platforms/ios/runner.xcodeproj/project.pbxproj index 42f99f6..18fb291 100644 --- a/platforms/ios/runner.xcodeproj/project.pbxproj +++ b/platforms/ios/runner.xcodeproj/project.pbxproj @@ -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; @@ -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; diff --git a/platforms/ios/runner/Info.plist b/platforms/ios/runner/Info.plist index fde681c..c8aef10 100644 --- a/platforms/ios/runner/Info.plist +++ b/platforms/ios/runner/Info.plist @@ -14,6 +14,8 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleName + Acode CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString diff --git a/platforms/ios/runner/lib/network/AppAPIHandler.swift b/platforms/ios/runner/lib/network/AppAPIHandler.swift index 9c4e784..64850cf 100644 --- a/platforms/ios/runner/lib/network/AppAPIHandler.swift +++ b/platforms/ios/runner/lib/network/AppAPIHandler.swift @@ -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 { @@ -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") + } + } } diff --git a/platforms/ios/runner/lib/network/HTTPTransport.swift b/platforms/ios/runner/lib/network/HTTPTransport.swift index 975944c..474f638 100644 --- a/platforms/ios/runner/lib/network/HTTPTransport.swift +++ b/platforms/ios/runner/lib/network/HTTPTransport.swift @@ -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 @@ -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) } } @@ -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 } } } diff --git a/platforms/ios/runner/lib/security/AppAuthentication.swift b/platforms/ios/runner/lib/security/AppAuthentication.swift index e793be4..cf3a620 100644 --- a/platforms/ios/runner/lib/security/AppAuthentication.swift +++ b/platforms/ios/runner/lib/security/AppAuthentication.swift @@ -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! } @@ -36,8 +35,9 @@ 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") @@ -45,28 +45,63 @@ struct AppAuthentication { 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" + } } diff --git a/platforms/ios/runner/lib/security/AuthenticatorService.swift b/platforms/ios/runner/lib/security/AuthenticatorService.swift index e319bed..f804868 100644 --- a/platforms/ios/runner/lib/security/AuthenticatorService.swift +++ b/platforms/ios/runner/lib/security/AuthenticatorService.swift @@ -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() @@ -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") } @@ -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 diff --git a/platforms/ios/runnerTests/AuthenticationTests.swift b/platforms/ios/runnerTests/AuthenticationTests.swift index 3a6f42a..3ee015d 100644 --- a/platforms/ios/runnerTests/AuthenticationTests.swift +++ b/platforms/ios/runnerTests/AuthenticationTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import WebKit import XCTest @testable import runner @@ -24,30 +25,34 @@ final class AuthenticationTests: XCTestCase { } func testLoginChallengeAndOneTimeExchangeMatchTheExistingServerProtocol() throws { - let url = try auth.begin(baseURL: "https://acode.app", version: 1011) - let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - let values = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value!) }) - XCTAssertEqual(url.path, "/login") - XCTAssertEqual(values["authFlow"], "app-code") - XCTAssertEqual(values["redirect"], "app") - XCTAssertEqual(values["appVersionCode"], "1011") - XCTAssertEqual(values["state"]?.count, 48) - let callback = URL(string: "acode://auth/callback?state=\(values["state"]!)&code=test-code")! - let request = try auth.consume(callback) - let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: request.httpBody!) as? [String: String]) - XCTAssertEqual(request.url?.absoluteString, "https://acode.app/api/user/app-token/exchange") - XCTAssertEqual(request.httpMethod, "POST") - XCTAssertEqual(payload["code"], "test-code") - XCTAssertEqual(payload["state"], values["state"]) - XCTAssertEqual(payload["verifier"]?.count, 64) - let digest = SHA256.hash(data: Data(payload["verifier"]!.utf8)).map { String(format: "%02x", $0) }.joined() - XCTAssertEqual(values["challenge"], digest) - XCTAssertThrowsError(try auth.consume(callback)) - XCTAssertEqual(try auth.secrets.get("pending_login"), "") + for origin in ["https://acode.app", "https://dev.acode.app"] { + let url = try auth.begin(baseURL: origin, version: 1011) + let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + let values = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value!) }) + XCTAssertEqual(url.host, URL(string: origin)?.host) + XCTAssertEqual(url.path, "/login") + XCTAssertEqual(values["authFlow"], "app-code") + XCTAssertEqual(values["redirect"], "app") + XCTAssertEqual(values["src"], "ios") + XCTAssertEqual(values["appVersionCode"], "1011") + XCTAssertEqual(values["state"]?.count, 48) + let callback = URL(string: "acode://auth/callback?state=\(values["state"]!)&code=test-code")! + let request = try auth.consume(callback) + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: request.httpBody!) as? [String: String]) + XCTAssertEqual(request.url?.absoluteString, origin + "/api/user/app-token/exchange") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(payload["code"], "test-code") + XCTAssertEqual(payload["state"], values["state"]) + XCTAssertEqual(payload["verifier"]?.count, 64) + let digest = SHA256.hash(data: Data(payload["verifier"]!.utf8)).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(values["challenge"], digest) + XCTAssertThrowsError(try auth.consume(callback)) + XCTAssertEqual(try auth.secrets.get("pending_login"), "") + } } func testRejectsForgedCallbacksUntrustedOriginsAndInvalidCookieTokens() throws { - for value in ["http://acode.app", "https://acode.app.evil.test", "https://acode.app:8443", "https://user@acode.app", "https://acode.app/path"] { + for value in ["http://acode.app", "https://acode.app.evil.test", "https://acode.app:8443", "https://user@acode.app", "https://acode.app/path", "http://dev.acode.app", "https://dev.acode.app.evil.test", "https://dev.acode.app:8443", "https://dev.acode.app?next=other"] { XCTAssertThrowsError(try auth.begin(baseURL: value, version: 1)) } let url = try auth.begin(baseURL: "https://acode.app", version: 1) @@ -62,4 +67,103 @@ final class AuthenticationTests: XCTestCase { XCTAssertEqual(try auth.secrets.get("auth_token"), "") XCTAssertEqual(try auth.secrets.get("pending_login"), "") } + + @MainActor + func testAPIRoutingKeepsDevelopmentAndProductionCredentialsSeparate() async throws { + let production = URL(string: "https://acode.app")! + let development = URL(string: "https://dev.acode.app")! + let previous = (HTTPCookieStorage.shared.cookies ?? []).filter { $0.name == "token" && AppAuthentication.hosts.contains($0.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) } + let unrelated = HTTPCookie(properties: [.name: "review-fixture-" + UUID().uuidString, .value: "other", .domain: "acode.app", .path: "/"])! + HTTPCookieStorage.shared.setCookie(unrelated) + defer { + HTTPCookieStorage.shared.deleteCookie(unrelated) + for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && AppAuthentication.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + previous.forEach(HTTPCookieStorage.shared.setCookie) + } + try auth.save("production-token") + try auth.save("development-token", for: development) + let dataStore = WKWebsiteDataStore.nonPersistent() + try await auth.synchronizeCookies(dataStore.httpCookieStore) + let webCookies = await dataStore.httpCookieStore.allCookies().filter { $0.name == "token" } + XCTAssertEqual(Set(webCookies.map(\.domain)), Set(AppAuthentication.hosts)) + let api = AppAPIHandler(auth: auth) + var source = URLRequest(url: URL(string: "acode://localhost/__api__/login?test=one%20two")!) + source.setValue("include", forHTTPHeaderField: "X-Acode-Credentials") + source.setValue("token=untrusted", forHTTPHeaderField: "Cookie") + for (origin, token) in [(production, "production-token"), (development, "development-token")] { + XCTAssertEqual((HTTPCookieStorage.shared.cookies(for: origin) ?? []).filter { $0.name == "token" }.map(\.value), [token]) + source.setValue(origin.absoluteString, forHTTPHeaderField: "X-Acode-API-Origin") + let request = try api.request(for: source) + XCTAssertEqual(request.url?.absoluteString, origin.absoluteString + "/api/login?test=one%20two") + let tokens = (request.value(forHTTPHeaderField: "Cookie") ?? "").components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }.filter { $0.hasPrefix("token=") } + XCTAssertEqual(tokens, ["token=" + token]) + XCTAssertFalse(request.httpShouldHandleCookies) + XCTAssertNil(request.value(forHTTPHeaderField: "X-Acode-API-Origin")) + } + source.setValue("omit", forHTTPHeaderField: "X-Acode-Credentials") + XCTAssertNil(try api.request(for: source).value(forHTTPHeaderField: "Cookie")) + source.setValue("https://dev.acode.app.evil.test", forHTTPHeaderField: "X-Acode-API-Origin") + XCTAssertThrowsError(try api.request(for: source)) + source.setValue(nil, forHTTPHeaderField: "X-Acode-API-Origin") + XCTAssertEqual(try api.request(for: source).url?.host, production.host) + try auth.logout() + XCTAssertEqual(try auth.token(for: production), "") + XCTAssertEqual(try auth.token(for: development), "") + } + + func testExchangeErrorsExplainRetryTimingAndExpiredSignIn() { + let url = URL(string: "https://dev.acode.app/api/user/app-token/exchange")! + let limited = HTTPURLResponse(url: url, statusCode: 429, httpVersion: nil, headerFields: ["Retry-After": "61"])! + XCTAssertEqual(AppAuthentication.exchangeFailure(limited).message, "Too many sign-in attempts. Please wait 2 minute(s) and try again.") + let expired = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)! + XCTAssertEqual(AppAuthentication.exchangeFailure(expired).message, "Sign-in expired. Please try again.") + } + + func testAPIResponseCookiesPersistUpdateAndExpireWithoutReplacingAccountTokens() throws { + let name = "response-fixture-" + UUID().uuidString + defer { + for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == name { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } + for host in AppAuthentication.hosts { + let origin = URL(string: "https://" + host)! + let url = origin.appendingPathComponent("api/cookie") + try auth.save("account-token", for: origin) + var source = URLRequest(url: URL(string: "acode://localhost/__api__/cookie")!) + source.setValue(origin.absoluteString, forHTTPHeaderField: "X-Acode-API-Origin") + source.setValue("include", forHTTPHeaderField: "X-Acode-Credentials") + let api = AppAPIHandler(auth: auth) + for (status, value) in [(200, "first"), (302, "updated")] { + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: ["Set-Cookie": "\(name)=\(value); Path=/api; Secure; HttpOnly, token=response-token; Path=/; Secure"])! + let before = (HTTPCookieStorage.shared.cookies(for: url) ?? []).first { $0.name == name }?.value + AppAPIHandler.storeCookies(from: response, credentials: false) + XCTAssertEqual((HTTPCookieStorage.shared.cookies(for: url) ?? []).first { $0.name == name }?.value, before) + AppAPIHandler.storeCookies(from: response, credentials: true) + let cookie = (HTTPCookieStorage.shared.cookies(for: url) ?? []).first { $0.name == name } + XCTAssertEqual(cookie?.value, value) + XCTAssertEqual(cookie?.isHTTPOnly, true) + XCTAssertFalse((HTTPCookieStorage.shared.cookies(for: url) ?? []).contains { $0.name == "token" && $0.value == "response-token" }) + let header = try api.request(for: source).value(forHTTPHeaderField: "Cookie") ?? "" + XCTAssertTrue(header.contains("\(name)=\(value)")) + XCTAssertTrue(header.contains("token=account-token")) + } + for expiry in ["Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT"] { + let renewed = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Set-Cookie": "\(name)=renewed; Path=/api; Secure"])! + AppAPIHandler.storeCookies(from: renewed, credentials: true) + let expired = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Set-Cookie": "\(name)=; Path=/api; \(expiry); Secure"])! + AppAPIHandler.storeCookies(from: expired, credentials: false) + XCTAssertEqual((HTTPCookieStorage.shared.cookies(for: url) ?? []).first { $0.name == name }?.value, "renewed") + AppAPIHandler.storeCookies(from: expired, credentials: true) + XCTAssertFalse((HTTPCookieStorage.shared.cookies(for: url) ?? []).contains { $0.name == name }) + } + XCTAssertEqual(try auth.token(for: origin), "account-token") + } + let foreign = URL(string: "https://untrusted.example/api/cookie")! + let response = HTTPURLResponse(url: foreign, statusCode: 200, httpVersion: nil, headerFields: ["Set-Cookie": "\(name)=foreign; Path=/"])! + AppAPIHandler.storeCookies(from: response, credentials: true) + XCTAssertFalse((HTTPCookieStorage.shared.cookies ?? []).contains { $0.name == name }) + } } diff --git a/platforms/ios/runnerTests/HTTPFixture.swift b/platforms/ios/runnerTests/HTTPFixture.swift index d34679e..94c30d1 100644 --- a/platforms/ios/runnerTests/HTTPFixture.swift +++ b/platforms/ios/runnerTests/HTTPFixture.swift @@ -128,6 +128,7 @@ final class HTTPFixture: @unchecked Sendable { case "/empty": status = "204 No Content"; data = Data() case "/error": status = "422 Unprocessable Entity"; data = Data("invalid request".utf8) case "/cookie": headers += "Set-Cookie: session=fixture; Path=/\r\n"; data = Data("cookie".utf8) + case "/cookie-redirect": status = "302 Found"; headers += "Location: /cookie-check\r\nSet-Cookie: session=redirect-fixture; Path=/\r\n"; data = Data() case "/cookie-check": data = Data(head.utf8) case "/stream": data = Data((0..<524288).map { UInt8($0 % 256) }) default: break diff --git a/platforms/ios/runnerTests/IOSBetaUITests.swift b/platforms/ios/runnerTests/IOSBetaUITests.swift index 35af008..037ef85 100644 --- a/platforms/ios/runnerTests/IOSBetaUITests.swift +++ b/platforms/ios/runnerTests/IOSBetaUITests.swift @@ -4,17 +4,17 @@ import WebKit @MainActor final class IOSBetaUITests: BridgeTestCase { - func testHomeBadgeAndTerminalInfoFollowActiveTab() async throws { + func testHomeAndTerminalInfoFollowActiveTab() async throws { let webView = try await editorWebView() let home = try await webView.callAsyncJavaScript(""" await acode.exec('welcome'); editorManager.getFile('welcome-tab').makeActive(); const badge=editorManager.getFile('welcome-tab').content.shadowRoot.querySelector('.beta-badge'); - return badge?.textContent === 'Beta' && getComputedStyle(badge).display !== 'none' + return !badge && !editorManager.header.querySelector('[data-action="terminal-info"]'); """, arguments: [:], in: nil, contentWorld: .page) as? Bool XCTAssertEqual(home, true) - await capture(webView, name: "iOS home Beta badge") + await capture(webView, name: "iOS home") defer { webView.evaluateJavaScript("document.querySelector('.prompt.alert button')?.click();editorManager.getFile('ios-beta-terminal')?.remove(true);editorManager.getFile('ios-beta-editor')?.remove(true);editorManager.getFile('welcome-tab')?.makeActive();") diff --git a/platforms/ios/runnerTests/SchemeRequestTests.swift b/platforms/ios/runnerTests/SchemeRequestTests.swift index 86cf399..ddae142 100644 --- a/platforms/ios/runnerTests/SchemeRequestTests.swift +++ b/platforms/ios/runnerTests/SchemeRequestTests.swift @@ -63,6 +63,42 @@ final class SchemeRequestTests: XCTestCase { HTTPCookieStorage.shared.deleteCookie(cookie) } } + + func testRedirectHookForwardsResponseCookieAndPreservesAccountToken() async throws { + let fixture = try HTTPFixture() + try await fixture.start() + defer { fixture.stop() } + var request = URLRequest(url: URL(string: fixture.origin + "/cookie-redirect")!) + request.httpShouldHandleCookies = false + request.setValue("token=account-fixture", forHTTPHeaderField: "Cookie") + let transport = HTTPTransport(request: request, followRedirects: true, connectTimeout: 5, readTimeout: 5) + defer { transport.cancel() } + let redirected = expectation(description: "Redirect cookie callback") + redirected.assertForOverFulfill = true + transport.onRedirect = { response, next in + XCTAssertEqual(response.statusCode, 302) + let header = try XCTUnwrap(response.value(forHTTPHeaderField: "Set-Cookie")) + let cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: try XCTUnwrap(response.url)) + let cookie = try XCTUnwrap(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"]) + let account = try XCTUnwrap(next.value(forHTTPHeaderField: "Cookie")) + next.setValue(cookie + "; " + account, forHTTPHeaderField: "Cookie") + redirected.fulfill() + } + let data: Data = try await withCheckedThrowingContinuation { continuation in + var body = Data() + transport.onData = { body.append($0) } + transport.onComplete = { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume(returning: body) } + } + transport.start() + } + await fulfillment(of: [redirected], timeout: 1) + let headers = String(decoding: data, as: UTF8.self).components(separatedBy: "\r\n") + let header = try XCTUnwrap(headers.first { $0.lowercased().hasPrefix("cookie:") }) + let cookies = Set(header.dropFirst("Cookie:".count).components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }) + XCTAssertEqual(cookies, ["session=redirect-fixture", "token=account-fixture"]) + } } private final class FixtureSchemeHandler: NSObject, WKURLSchemeHandler { diff --git a/platforms/ios/runnerTests/SplashTests.swift b/platforms/ios/runnerTests/SplashTests.swift index fda1ea3..5a234d9 100644 --- a/platforms/ios/runnerTests/SplashTests.swift +++ b/platforms/ios/runnerTests/SplashTests.swift @@ -54,8 +54,6 @@ final class SplashTests: BridgeTestCase { } let loading = try await webView.evaluateJavaScript("document.body.classList.contains('loading')") as? Bool XCTAssertEqual(loading, true) - let badge = try await webView.evaluateJavaScript("const badge=document.querySelector('.splash-beta');badge.textContent==='Beta'&&getComputedStyle(badge).display!=='none'") as? Bool - XCTAssertEqual(badge, true) XCTAssertEqual(controller.view.backgroundColor, color, saved) XCTAssertEqual(webView.backgroundColor, color, saved) XCTAssertEqual(webView.window?.backgroundColor, color, saved) diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js index 670ea19..5b734c8 100644 --- a/src/components/sidebar/index.js +++ b/src/components/sidebar/index.js @@ -7,6 +7,8 @@ import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; import auth, { loginEvents } from "lib/auth"; import config from "lib/config"; +import customTab from "lib/customTab"; +import platform from "lib/platform"; import helpers from "utils/helpers"; import Url from "utils/Url"; @@ -73,7 +75,10 @@ function create($container, $toggler) {
- {/*
*/} +
+ + {strings.settings} +
{strings.logout} @@ -127,7 +132,7 @@ function create($container, $toggler) { await auth.login(); user = await auth.getLoggedInUser(); if (!user) { - return; + throw new Error("Sign-in did not complete. Please try again."); } } @@ -162,11 +167,34 @@ function create($container, $toggler) { } } catch (error) { console.error("Error checking login status:", error); + helpers.error(error); } finally { loader.destroy(); } } + async function handleAccountSettings() { + userContextMenu.el.classList.remove("active"); + document.removeEventListener("click", handleClickOutside); + try { + if (platform.isIOS) { + const user = await auth.openAccount(); + if (user === undefined) { + toast( + "Unable to refresh account status. Please check your connection.", + ); + return; + } + await updateSidebarAvatar(user); + if (!user) toast("You are signed out. Sign in again to continue."); + } else { + await customTab(`${config.BASE_URL}/profile/edit`); + } + } catch (error) { + helpers.error(error); + } + } + function handleClickOutside(e) { if ( !userContextMenu.el.contains(e.target) && @@ -206,9 +234,9 @@ function create($container, $toggler) { } } - async function updateSidebarAvatar() { + async function updateSidebarAvatar(user) { const defaultAvatar = ; - const user = await auth.getLoggedInUser(); + if (user === undefined) user = await auth.getLoggedInUser(); userAvatar.content = defaultAvatar; diff --git a/src/index.html b/src/index.html index 1bb9cf6..4ad64da 100644 --- a/src/index.html +++ b/src/index.html @@ -63,26 +63,6 @@ text-align: left; } - .splash-beta { - display: none; - position: absolute; - top: calc(50% + 110px); - left: 50%; - transform: translateX(-50%); - padding: 4px 12px; - border: 1px solid currentColor; - border-radius: 16px; - color: var(--primary-text-color, rgb(209, 209, 209)); - background-color: var(--secondary-color, #20202c); - font: 600 11px/1.4 sans-serif; - letter-spacing: 1px; - text-transform: uppercase; - } - - html[data-platform="ios"] .splash-beta { - display: inline-block; - } - .splash-message { position: absolute; bottom: 15px; @@ -226,7 +206,6 @@
- Beta
diff --git a/src/lib/auth.js b/src/lib/auth.js index 5ed9f2f..27ec635 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -55,11 +55,16 @@ class AuthService { } this.#loginCallbacks.clear(); }); + document.addEventListener("pause", () => { + clearTimeout(this.#loginTimeout); + }); document.addEventListener("resume", () => { clearTimeout(this.#loginTimeout); + if (!this.#loginCallbacks.size) return; + // Browser sign-in can take minutes; only bound the handoff back to Acode. this.#loginTimeout = setTimeout(() => { for (const callback of this.#loginCallbacks) { - callback.reject("Login timed out"); + callback.reject(new Error("Sign-in timed out. Please try again.")); } this.#loginCallbacks.clear(); @@ -100,6 +105,11 @@ class AuthService { } } + async openAccount() { + await this.#exec("openAccount", [{ baseUrl: config.BASE_URL }]); + return this.getLoggedInUser(true).catch(() => undefined); + } + /** * @param {boolean} forceFetch * @returns {Promise} @@ -108,7 +118,9 @@ class AuthService { if (loggedInUser && !forceFetch) return loggedInUser; try { - const res = await fetch(`${config.API_BASE}/login`); + const res = await fetch(`${config.API_BASE}/login`, { + cache: "no-store", + }); if (res.ok) { loggedInUser = await res.json(); @@ -119,13 +131,15 @@ class AuthService { } if (res.status === 401) { + loggedInUser = null; + clearTimeout(cacheTimeout); localStorage.removeItem(CACHE_USER_KEY); return null; } throw new Error("Unable to fetch user Info"); } catch (error) { - if (CACHE_USER_KEY in localStorage) { + if (!forceFetch && CACHE_USER_KEY in localStorage) { try { return JSON.parse(localStorage.getItem(CACHE_USER_KEY)); } catch {} @@ -139,6 +153,7 @@ class AuthService { return new Promise((resolve, reject) => { const callback = { resolve, reject }; this.#loginCallbacks.add(callback); + if (this.#loginCallbacks.size > 1) return; this.#exec("login", [ { baseUrl: config.BASE_URL, @@ -146,12 +161,22 @@ class AuthService { }, ]) .then(() => { + if (!this.#loginCallbacks.has(callback)) return; + loggedInUser = null; + clearTimeout(cacheTimeout); + localStorage.removeItem(CACHE_USER_KEY); loginEvents.emit(); }) .catch((err) => { + if (!this.#loginCallbacks.has(callback)) return; console.error("Native login error", err); - this.#loginCallbacks.delete(callback); - reject("Failed to login"); + clearTimeout(this.#loginTimeout); + const error = + err instanceof Error + ? err + : new Error(String(err || "Sign-in failed. Please try again.")); + for (const pending of this.#loginCallbacks) pending.reject(error); + this.#loginCallbacks.clear(); }); }); } diff --git a/src/lib/polyfill.js b/src/lib/polyfill.js index d8cab0b..d95c408 100644 --- a/src/lib/polyfill.js +++ b/src/lib/polyfill.js @@ -1,12 +1,23 @@ // automatically include credentials for acode.app API requests (function () { - const _fetch = window.fetch; - window.fetch = function (url, options) { + const transport = window.fetch.bind(window); + const appFetch = function (url, options) { if (typeof url === "string" && url.includes("acode.app/api")) { options = { ...options, credentials: "include" }; } - return _fetch.call(this, url, options); + return transport(url, options); }; + Object.defineProperty(window, "fetch", { + configurable: false, + enumerable: true, + get: () => appFetch, + // Warn instead of throwing so legacy wrappers do not break startup. + set() { + console.warn( + "Acode blocked an attempt to replace window.fetch. Use a local fetch wrapper instead.", + ); + }, + }); })(); // polyfill for prepend diff --git a/src/pages/changelog/changelog.js b/src/pages/changelog/changelog.js index fc694b8..42187ea 100644 --- a/src/pages/changelog/changelog.js +++ b/src/pages/changelog/changelog.js @@ -6,6 +6,7 @@ import toast from "components/toast"; import DOMPurify from "dompurify"; import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; +import platform from "lib/platform"; import markdownIt from "markdown-it"; import markdownItFootnote from "markdown-it-footnote"; import markdownItTaskLists from "markdown-it-task-lists"; @@ -52,9 +53,7 @@ export default async function Changelog() {
  • Latest Release
  • -
  • - Beta Version -
  • + ${platform.isIOS ? "" : `
  • Beta Version
  • `}
  • Full Changelog
  • @@ -186,6 +185,7 @@ export default async function Changelog() { await loadLatestRelease(); break; case "beta": + if (platform.isIOS) return; await loadBetaRelease(); break; case "full": diff --git a/src/pages/welcome/welcome.js b/src/pages/welcome/welcome.js index ef6c725..191e599 100644 --- a/src/pages/welcome/welcome.js +++ b/src/pages/welcome/welcome.js @@ -47,10 +47,7 @@ function createWelcomeContent() {
    -

    - Welcome to Acode - {platform.isIOS && Beta} -

    +

    Welcome to Acode

    Powerful code editor for {platform.isIOS ? "iOS" : "Android"}

    diff --git a/src/pages/welcome/welcome.scss b/src/pages/welcome/welcome.scss index 98e5280..7b0f647 100644 --- a/src/pages/welcome/welcome.scss +++ b/src/pages/welcome/welcome.scss @@ -37,18 +37,6 @@ letter-spacing: -0.3px; } - .beta-badge { - padding: 2px 7px; - border: 1px solid var(--active-color); - border-radius: 12px; - color: var(--active-color); - font-size: 10px; - font-weight: 600; - line-height: 1.4; - letter-spacing: 0.6px; - text-transform: uppercase; - } - .tagline { font-size: 13px; color: color-mix(in srgb, var(--secondary-text-color) 60%, transparent); diff --git a/src/platforms/ios/api.ts b/src/platforms/ios/api.ts index 3fa53df..868bd74 100644 --- a/src/platforms/ios/api.ts +++ b/src/platforms/ios/api.ts @@ -13,6 +13,7 @@ export default function installAPITransport() { if (request.mode === "same-origin") throw new TypeError("Cross-origin request is not allowed"); const headers = new Headers(request.headers); + headers.set("X-Acode-API-Origin", new URL(request.url).origin); headers.set("X-Acode-Credentials", request.credentials); headers.set("X-Acode-Redirect", request.redirect); const response = await fetch.call(window, target, { @@ -52,7 +53,7 @@ function localURL(value: string) { try { const url = new URL(value, location.href); if ( - url.origin !== "https://acode.app" || + !["https://acode.app", "https://dev.acode.app"].includes(url.origin) || url.username || url.password || !(url.pathname === "/api" || url.pathname.startsWith("/api/")) diff --git a/src/platforms/ios/apiXHR.ts b/src/platforms/ios/apiXHR.ts index 6dc1b79..b0de391 100644 --- a/src/platforms/ios/apiXHR.ts +++ b/src/platforms/ios/apiXHR.ts @@ -56,6 +56,11 @@ export default function installAPIXHR( "Binary API uploads require asynchronous XHR on iOS", "InvalidAccessError", ); + setRequestHeader.call( + this, + "X-Acode-API-Origin", + new URL(state.url, location.href).origin, + ); setRequestHeader.call( this, "X-Acode-Credentials", diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js new file mode 100644 index 0000000..36f833c --- /dev/null +++ b/tests/unit/auth.test.js @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +let auth; +let loginEvents; +let exec; +let request; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + vi.stubGlobal("document", new EventTarget()); + vi.stubGlobal("window", { BuildInfo: { versionCode: 1016 } }); + vi.stubGlobal("localStorage", { + getItem(key) { return this[key] ?? null; }, + setItem(key, value) { this[key] = value; }, + removeItem(key) { delete this[key]; }, + }); + exec = vi.fn(); + request = vi.fn(); + vi.stubGlobal("Bridge", { exec }); + vi.stubGlobal("fetch", request); + ({ default: auth, loginEvents } = await import("../../src/lib/auth")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("coalesces native login without an extra request and invalidates the previous account", async () => { + request.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7 }))); + await auth.getLoggedInUser(); + request.mockClear(); + request.mockRejectedValue(new Error("Offline after native sign-in")); + const completed = vi.fn(); + loginEvents.addListener(completed); + const first = auth.login(); + const second = auth.login(); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec.mock.calls[0].slice(2)).toEqual(["Authenticator", "login", [{ baseUrl: "https://acode.app", appVersionCode: 1016 }]]); + exec.mock.calls[0][0](); + await Promise.all([first, second]); + expect(completed).toHaveBeenCalledOnce(); + expect(request).not.toHaveBeenCalled(); + expect(localStorage["cached-logged-in-user"]).toBeUndefined(); + request.mockResolvedValue(new Response(JSON.stringify({ id: 8 }))); + expect((await auth.getLoggedInUser()).id).toBe(8); + expect(request).toHaveBeenCalledWith("https://acode.app/api/login", { cache: "no-store" }); +}); + +test("a rejected account refresh clears cached account state", async () => { + request.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7 }))); + await auth.getLoggedInUser(); + request.mockImplementation(async () => new Response("{}", { status: 401 })); + expect(await auth.getLoggedInUser(true)).toBeNull(); + expect(await auth.getLoggedInUser()).toBeNull(); + expect(localStorage["cached-logged-in-user"]).toBeUndefined(); +}); + +test("an idle resume cannot time out a later browser sign-in", async () => { + document.dispatchEvent(new Event("resume")); + await vi.advanceTimersByTimeAsync(50_000); + const completed = vi.fn(); + loginEvents.addListener(completed); + const pending = auth.login(); + const outcome = pending.then(() => "success", (error) => error.message); + await vi.advanceTimersByTimeAsync(20_000); + exec.mock.calls[0][0](); + expect(await outcome).toBe("success"); + expect(completed).toHaveBeenCalledOnce(); +}); + +test("returning to the browser cancels the resume deadline until the next return", async () => { + const pending = auth.login(); + const outcome = pending.then(() => "success", (error) => error.message); + document.dispatchEvent(new Event("resume")); + await vi.advanceTimersByTimeAsync(30_000); + document.dispatchEvent(new Event("pause")); + await vi.advanceTimersByTimeAsync(90_000); + document.dispatchEvent(new Event("resume")); + exec.mock.calls[0][0](); + expect(await outcome).toBe("success"); +}); + +test("preserves actionable native errors for every waiter and permits a new attempt", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const message = "Too many sign-in attempts. Please wait 2 minutes and try again."; + const first = expect(auth.login()).rejects.toThrow(message); + const second = expect(auth.login()).rejects.toThrow(message); + exec.mock.calls[0][1](message); + await Promise.all([first, second]); + const retry = expect(auth.login()).rejects.toThrow("Sign-in cancelled"); + expect(exec).toHaveBeenCalledTimes(2); + exec.mock.calls[1][1]("Sign-in cancelled"); + await retry; + + const timedOut = expect(auth.login()).rejects.toThrow("Sign-in timed out"); + document.dispatchEvent(new Event("resume")); + await vi.advanceTimersByTimeAsync(60_000); + await timedOut; + const nextAttempt = expect(auth.login()).rejects.toThrow("Current attempt failed"); + exec.mock.calls[2][1]("Previous attempt cancelled"); + await Promise.resolve(); + exec.mock.calls[3][1]("Current attempt failed"); + await nextAttempt; +}); + +test("a forced account refresh cannot fall back to an offline cached account", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem("cached-logged-in-user", JSON.stringify({ id: 7 })); + request.mockRejectedValue(new Error("Connection failed")); + await expect(auth.getLoggedInUser()).resolves.toEqual({ id: 7 }); + await expect(auth.getLoggedInUser(true)).rejects.toThrow("Connection failed"); +}); + +test("account settings use the authentication browser and refresh after it closes", async () => { + request.mockResolvedValue(new Response("{}", { status: 401 })); + const pending = auth.openAccount(); + const [closed, , service, action, args] = exec.mock.calls[0]; + expect([service, action, args]).toEqual(["Authenticator", "openAccount", [{ baseUrl: "https://acode.app" }]]); + expect(request).not.toHaveBeenCalled(); + closed(); + await expect(pending).resolves.toBeNull(); + expect(request).toHaveBeenCalledTimes(1); +}); + +test("closing account settings offline skips refresh without reporting a signed-out session", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + request.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7 }))); + await auth.getLoggedInUser(); + await vi.advanceTimersByTimeAsync(600_000); + request.mockRejectedValue(new Error("Offline")); + const pending = auth.openAccount(); + exec.mock.calls[0][0](); + await expect(pending).resolves.toBeUndefined(); +}); diff --git a/tests/unit/fetchProtection.test.js b/tests/unit/fetchProtection.test.js new file mode 100644 index 0000000..2a8dbb3 --- /dev/null +++ b/tests/unit/fetchProtection.test.js @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import installAPITransport from "../../src/platforms/ios/api"; + +const source = readFileSync( + new URL("../../src/lib/polyfill.js", import.meta.url), + "utf8", +); +let window; +let transport; +let warning; + +beforeEach(() => { + window = new Window({ url: "https://localhost/" }); + transport = vi.fn(async () => new Response("authenticated")); + window.fetch = transport; + warning = vi.spyOn(window.console, "warn").mockImplementation(() => {}); +}); + +afterEach(async () => { + await window.happyDOM.abort(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test("plugins cannot replace, redefine or delete the host fetch transport", async () => { + window.eval(source); + const fetch = window.fetch; + // Legacy wrappers also assign in strict mode; they must not break startup. + window.eval( + '"use strict"; window.fetch = () => { throw Error("replaced"); };', + ); + expect(window.fetch).toBe(fetch); + expect(warning).toHaveBeenCalledExactlyOnceWith( + "Acode blocked an attempt to replace window.fetch. Use a local fetch wrapper instead.", + ); + expect(Reflect.defineProperty(window, "fetch", { value: vi.fn() })).toBe( + false, + ); + expect(Reflect.deleteProperty(window, "fetch")).toBe(false); + const response = await window.fetch("https://dev.acode.app/api/login", { + cache: "no-store", + }); + expect(await response.text()).toBe("authenticated"); + expect(transport).toHaveBeenLastCalledWith( + "https://dev.acode.app/api/login", + { + cache: "no-store", + credentials: "include", + }, + ); + const request = new window.Request("https://example.test/provider", { + method: "POST", + body: "message", + }); + await window.fetch(request, { credentials: "omit" }); + expect(transport).toHaveBeenLastCalledWith(request, { + credentials: "omit", + }); +}); + +test("protects the already installed iOS transport and preserves authenticated API routing", async () => { + for (const [name, value] of Object.entries({ + window, + location: window.location, + XMLHttpRequest: window.XMLHttpRequest, + Request: window.Request, + Headers: window.Headers, + })) + vi.stubGlobal(name, value); + installAPITransport(); + window.eval(source); + for (const origin of ["https://acode.app", "https://dev.acode.app"]) { + const response = await window.fetch(`${origin}/api/login`, { + cache: "no-store", + }); + const [url, options] = transport.mock.calls.at(-1); + expect(url).toBe("acode://localhost/__api__/login"); + expect(options.headers.get("X-Acode-API-Origin")).toBe(origin); + expect(options.headers.get("X-Acode-Credentials")).toBe("include"); + expect(await response.text()).toBe("authenticated"); + } +}); diff --git a/tests/unit/iosApi.test.ts b/tests/unit/iosApi.test.ts index 3a934ed..9735b1d 100644 --- a/tests/unit/iosApi.test.ts +++ b/tests/unit/iosApi.test.ts @@ -48,14 +48,25 @@ test("normalizes multipart XHR and cancels pending body preparation", async () = afterEach(() => vi.unstubAllGlobals()); test("only routes Acode HTTPS API requests through the iOS transport", async () => { - for (const url of ["https://example.com/api/login", "https://acode.app.evil.test/api/login", "http://acode.app/api/login", "https://acode.app:8443/api/login", "https://acode.app/plugins", "acode://localhost/__cdvfile_cache__/file.txt"]) { + for (const url of ["https://example.com/api/login", "https://acode.app.evil.test/api/login", "http://acode.app/api/login", "https://acode.app:8443/api/login", "https://acode.app/plugins", "https://dev.acode.app.evil.test/api/login", "http://dev.acode.app/api/login", "https://dev.acode.app:8443/api/login", "acode://localhost/__cdvfile_cache__/file.txt"]) { await window.fetch(url); expect(transport.mock.calls.at(-1)?.[0]).toBe(url); } - await window.fetch("https://acode.app/api/login?test=one%20two", { credentials: "include" }); - const [url, options] = transport.mock.calls.at(-1)!; - expect(url).toBe("acode://localhost/__api__/login?test=one%20two"); - expect(options.headers.get("X-Acode-Credentials")).toBe("include"); + for (const origin of ["https://acode.app", "https://dev.acode.app"]) { + await window.fetch(`${origin}/api/login?test=one%20two`, { credentials: "include", headers: { "X-Acode-API-Origin": "https://example.com" } }); + const [url, options] = transport.mock.calls.at(-1)!; + expect(url).toBe("acode://localhost/__api__/login?test=one%20two"); + expect(options.headers.get("X-Acode-Credentials")).toBe("include"); + expect(options.headers.get("X-Acode-API-Origin")).toBe(origin); + const xhr = new XMLHttpRequest(); + xhr.open("GET", `${origin}/api/login`); + xhr.withCredentials = true; + xhr.send(); + const state = xhr as unknown as { openArgs: unknown[]; headers: Record }; + expect(state.openArgs[1]).toBe("acode://localhost/__api__/login"); + expect(state.headers["X-Acode-API-Origin"]).toBe(origin); + expect(state.headers["X-Acode-Credentials"]).toBe("include"); + } }); test("retains Request bodies, content types, cancellation and response URLs", async () => { diff --git a/tests/unit/iosBetaBadge.test.js b/tests/unit/iosBetaBadge.test.js deleted file mode 100644 index fe19a7d..0000000 --- a/tests/unit/iosBetaBadge.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs"; -import vm from "node:vm"; -import { Window } from "happy-dom"; -import { expect, test, vi } from "vitest"; - -const html = fs.readFileSync( - new URL("../../src/index.html", import.meta.url), - "utf8", -); - -test.each([ - false, - true, -])("shows the splash Beta badge only on iOS: %s", (ios) => { - const window = new Window(); - window.document.head.innerHTML = html.match(/