101 lines
4.6 KiB
Swift
101 lines
4.6 KiB
Swift
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)
|
|
}
|
|
}
|