feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
struct AuthUser: Codable, Equatable {
|
||||
let id: String
|
||||
let username: String
|
||||
let displayName: String?
|
||||
let role: String
|
||||
let status: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, role, status
|
||||
case displayName = "display_name"
|
||||
}
|
||||
}
|
||||
struct AuthLoginResponse: Decodable {
|
||||
let token: String
|
||||
let user: AuthUser
|
||||
let expiresAt: Double
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token, user
|
||||
case expiresAt = "expires_at"
|
||||
}
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
token = try c.decode(String.self, forKey: .token)
|
||||
user = try c.decode(AuthUser.self, forKey: .user)
|
||||
if let seconds = try? c.decode(Double.self, forKey: .expiresAt) { expiresAt = seconds }
|
||||
else {
|
||||
let raw = try c.decode(String.self, forKey: .expiresAt)
|
||||
guard let date = ISO8601DateFormatter().date(from: raw) else { throw AuthError.invalidResponse }
|
||||
expiresAt = date.timeIntervalSince1970
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
enum ConnectionFailure {
|
||||
static func message(_ error: Error, server: URL) -> String {
|
||||
let ns = error as NSError
|
||||
let certificateCodes: Set<Int> = [-1200, -1201, -1202, -1203, -1204, -1205, -1206]
|
||||
if ns.domain == NSURLErrorDomain && certificateCodes.contains(ns.code) {
|
||||
return "The certificate for \(server.host ?? "this server") is not trusted. For your private Hope server, install the verified certificate profile in Safari, then enable it in Settings → General → About → Certificate Trust Settings. Do not continue if the server address is unfamiliar."
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
enum AuthError: LocalizedError {
|
||||
case status(Int)
|
||||
case invalidResponse
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .status(401): return "Your sign-in expired or the username and password were not accepted. Sign in again."
|
||||
case .status(403): return "This account is not authorized. Contact your server administrator."
|
||||
case .status: return "The server could not complete sign-in. Try again later."
|
||||
case .invalidResponse: return "The server returned an unexpected authentication response."
|
||||
}
|
||||
}
|
||||
}
|
||||
final class AuthClient {
|
||||
let config: ServerConfig
|
||||
private let session: URLSession
|
||||
init(config: ServerConfig, session: URLSession? = nil) {
|
||||
self.config = config
|
||||
self.session = session ?? TLSPinning.session()
|
||||
}
|
||||
private func request(_ path: String, method: String, token: String? = nil, body: Data? = nil) async throws -> Data {
|
||||
var request = URLRequest(url: config.baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = 10
|
||||
request.httpBody = body
|
||||
if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
|
||||
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw AuthError.invalidResponse }
|
||||
guard (200..<300).contains(http.statusCode) else { throw AuthError.status(http.statusCode) }
|
||||
return data
|
||||
}
|
||||
func login(username: String, password: String) async throws -> AuthLoginResponse {
|
||||
let body = try JSONSerialization.data(withJSONObject: ["username": username, "password": password])
|
||||
return try JSONDecoder().decode(AuthLoginResponse.self, from: await request("api/auth/login", method: "POST", body: body))
|
||||
}
|
||||
func me(token: String) async throws -> AuthUser {
|
||||
struct Response: Decodable { let user: AuthUser }
|
||||
return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user
|
||||
}
|
||||
func voices(token: String) async throws -> VoiceCatalogResponse {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
|
||||
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
|
||||
return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all,
|
||||
catalogAvailable: false, message: "Offline test voice catalog.")
|
||||
}
|
||||
#endif
|
||||
guard !token.isEmpty else { throw AuthError.status(401) }
|
||||
return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token))
|
||||
}
|
||||
|
||||
func logout(token: String) async throws {
|
||||
_ = try await request("api/auth/logout", method: "POST", token: token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol TokenStore {
|
||||
func token(origin: String) throws -> String?
|
||||
func save(_ token: String?, origin: String) throws
|
||||
}
|
||||
struct KeychainTokenStore: TokenStore {
|
||||
private func query(_ origin: String) -> [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "dev.shane.voice.auth",
|
||||
kSecAttrAccount as String: origin]
|
||||
}
|
||||
func token(origin: String) throws -> String? {
|
||||
var q = query(origin)
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(q as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else { throw StoreError.failed }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
func save(_ token: String?, origin: String) throws {
|
||||
let q = query(origin)
|
||||
let deletion = SecItemDelete(q as CFDictionary)
|
||||
guard deletion == errSecSuccess || deletion == errSecItemNotFound else { throw StoreError.failed }
|
||||
guard let token else { return }
|
||||
var item = q
|
||||
item[kSecValueData as String] = Data(token.utf8)
|
||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw StoreError.failed }
|
||||
}
|
||||
enum StoreError: LocalizedError {
|
||||
case failed
|
||||
var errorDescription: String? { "Could not securely save the sign-in on this iPhone." }
|
||||
}
|
||||
}
|
||||
final class MemoryTokenStore: TokenStore {
|
||||
private var values: [String: String] = [:]
|
||||
func token(origin: String) throws -> String? { values[origin] }
|
||||
func save(_ token: String?, origin: String) throws { values[origin] = token }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct ServerConfig: Equatable {
|
||||
let baseURL: URL
|
||||
let livekitURL: URL
|
||||
|
||||
enum ConfigError: LocalizedError, Equatable {
|
||||
case notHTTPS(String)
|
||||
case invalidURL(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notHTTPS(let raw):
|
||||
return "Server URL must use https: \(raw)"
|
||||
case .invalidURL(let raw):
|
||||
return "Invalid server URL: \(raw)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func parse(_ raw: String) -> Result<ServerConfig, ConfigError> {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .failure(.invalidURL(raw)) }
|
||||
var candidate = trimmed
|
||||
if !candidate.contains("://") { candidate = "https://" + candidate }
|
||||
guard let components = URLComponents(string: candidate),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host, !host.isEmpty else {
|
||||
return .failure(.invalidURL(raw))
|
||||
}
|
||||
if scheme != "https" { return .failure(.notHTTPS(raw)) }
|
||||
guard components.user == nil, components.password == nil,
|
||||
components.query == nil, components.fragment == nil else {
|
||||
return .failure(.invalidURL(raw))
|
||||
}
|
||||
var c = components
|
||||
c.scheme = "https"
|
||||
c.host = host.lowercased()
|
||||
if c.port == 443 { c.port = nil }
|
||||
c.path = ""
|
||||
guard let base = c.url else { return .failure(.invalidURL(raw)) }
|
||||
c.scheme = "wss"
|
||||
c.path = "/livekit"
|
||||
guard let livekit = c.url else { return .failure(.invalidURL(raw)) }
|
||||
return .success(ServerConfig(baseURL: base, livekitURL: livekit))
|
||||
}
|
||||
|
||||
static func isPrivateIP(_ host: String) -> Bool {
|
||||
let parts = host.split(separator: ".").compactMap { UInt8($0) }
|
||||
guard parts.count == 4 else { return false }
|
||||
return parts[0] == 10
|
||||
|| (parts[0] == 172 && (16...31).contains(parts[1]))
|
||||
|| (parts[0] == 192 && parts[1] == 168)
|
||||
}
|
||||
|
||||
var host: String { baseURL.host ?? "" }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import Security
|
||||
|
||||
/// Pins the self-signed cert of the LAN deployment (CN=192.168.86.2, regenerated
|
||||
/// at container start — re-export the hash after server cert rotation, see README).
|
||||
/// Extract with:
|
||||
/// openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null \
|
||||
/// | openssl x509 -outform der | openssl dgst -sha256
|
||||
enum TLSPinning {
|
||||
static let pinnedCertSHA256Hex: Set<String> = [
|
||||
"07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46"
|
||||
]
|
||||
|
||||
static let host = "192.168.86.2"
|
||||
|
||||
static func session() -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.urlCache = nil
|
||||
config.httpCookieStorage = nil
|
||||
config.urlCredentialStorage = nil
|
||||
config.timeoutIntervalForRequest = 15
|
||||
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
|
||||
}
|
||||
|
||||
static func sha256Hex(_ data: Data) -> String {
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { buffer in
|
||||
guard let base = buffer.baseAddress else { return }
|
||||
CC_SHA256(base, CC_LONG(data.count), &hash)
|
||||
}
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func matchesPinnedCert(_ trust: SecTrust) -> Bool {
|
||||
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let leaf = chain.first,
|
||||
let der = SecCertificateCopyData(leaf) as Data? else { return false }
|
||||
return pinnedCertSHA256Hex.contains(sha256Hex(der))
|
||||
}
|
||||
}
|
||||
|
||||
final class PinnedTrustDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
// Authentication/password-bearing requests must never be redirected to
|
||||
// another endpoint or origin. Correct the configured server instead.
|
||||
completionHandler(nil)
|
||||
}
|
||||
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
// Apply the fixed self-signed pin only to the pinned LAN deployment
|
||||
// host. Other explicitly configured HTTPS hosts use normal system
|
||||
// trust evaluation (Finding 12). This allows public deployments and
|
||||
// other LAN hosts with valid certificates while never weakening trust
|
||||
// for the default self-signed host.
|
||||
let host = challenge.protectionSpace.host
|
||||
if host == TLSPinning.host {
|
||||
if TLSPinning.matchesPinnedCert(trust) {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
} else {
|
||||
// Non-pinned host: let the system evaluate the certificate chain.
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
struct TokenResponse: Decodable, Equatable {
|
||||
let token: String
|
||||
let room: String
|
||||
let user: User
|
||||
let conversationID: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token, room, user
|
||||
case conversationID = "conversation_id"
|
||||
}
|
||||
|
||||
struct User: Decodable, Equatable {
|
||||
let id: String
|
||||
let displayName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case displayName = "display_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenError: LocalizedError {
|
||||
case invalidServerURL(String)
|
||||
case httpStatus(Int, String)
|
||||
case decodeFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServerURL(let raw):
|
||||
return "Invalid server URL: \(raw)"
|
||||
case .httpStatus(let code, let detail):
|
||||
if code == 503 {
|
||||
return "Server is starting up or the user file is missing (HTTP 503). Check the deployment on the server. \(detail)"
|
||||
}
|
||||
return "Token request failed with HTTP \(code). \(detail)"
|
||||
case .decodeFailed:
|
||||
return "Unexpected response from the token endpoint."
|
||||
case .network(let error):
|
||||
return "Could not reach the server: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TokenClient {
|
||||
let config: ServerConfig
|
||||
private let session: URLSession
|
||||
var onUnauthorized: (() async -> Void)?
|
||||
let bearerToken: String
|
||||
var conversationID: String?
|
||||
|
||||
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
|
||||
self.config = config
|
||||
self.session = session ?? TLSPinning.session()
|
||||
self.bearerToken = bearerToken
|
||||
}
|
||||
|
||||
func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } }
|
||||
|
||||
func fetchToken() async throws -> TokenResponse {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
|
||||
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
|
||||
throw URLError(.cannotConnectToHost) // offline fixtures never contact any configured server
|
||||
}
|
||||
#endif
|
||||
guard !bearerToken.isEmpty else { throw AuthError.status(401) }
|
||||
var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
|
||||
request.httpMethod = "POST"
|
||||
if let conversationID {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID])
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 10
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw TokenError.decodeFailed
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() }
|
||||
let detail = ""
|
||||
throw TokenError.httpStatus(http.statusCode, detail)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
} catch {
|
||||
throw TokenError.decodeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user