Add detailed logging to auth flow on backend and iOS
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
import OSLog
|
||||
|
||||
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthService")
|
||||
|
||||
final class AuthService: NSObject {
|
||||
static let shared = AuthService()
|
||||
@@ -29,6 +32,9 @@ final class AuthService: NSObject {
|
||||
throw AuthError.invalidURL
|
||||
}
|
||||
|
||||
logger.info("Starting login — auth URL: \(authURL.absoluteString)")
|
||||
logger.info("Callback URL scheme: \(AppConfig.authCallbackURL)")
|
||||
|
||||
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
|
||||
|
||||
// Use an ephemeral session — we only need the redirect URL back with the
|
||||
@@ -40,8 +46,10 @@ final class AuthService: NSObject {
|
||||
if let error = error {
|
||||
let authError: AuthError
|
||||
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||
logger.info("Login cancelled by user")
|
||||
authError = .cancelled
|
||||
} else {
|
||||
logger.error("ASWebAuthenticationSession error: \(error)")
|
||||
authError = .failed(error.localizedDescription)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
@@ -55,6 +63,7 @@ final class AuthService: NSObject {
|
||||
}
|
||||
|
||||
guard let callbackURL = callbackURL else {
|
||||
logger.error("ASWebAuthenticationSession returned nil callbackURL")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
@@ -91,6 +100,7 @@ final class AuthService: NSObject {
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
|
||||
else {
|
||||
logger.error("Callback URL missing code or state: \(url.absoluteString)")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
@@ -103,11 +113,13 @@ final class AuthService: NSObject {
|
||||
|
||||
Task {
|
||||
do {
|
||||
logger.info("Exchanging code for tokens (state: \(state), redirect_uri: \(AppConfig.authCallbackURL))")
|
||||
let tokenResponse = try await exchangeCodeForTokens(
|
||||
code: code,
|
||||
state: state,
|
||||
redirectUri: AppConfig.authCallbackURL
|
||||
)
|
||||
logger.info("Token exchange succeeded for user: \(tokenResponse.user.id)")
|
||||
|
||||
await AuthManager.shared.handleTokenResponse(tokenResponse)
|
||||
|
||||
@@ -115,6 +127,7 @@ final class AuthService: NSObject {
|
||||
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Token exchange failed: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
@@ -152,12 +165,17 @@ final class AuthService: NSObject {
|
||||
throw AuthError.failed("Invalid response")
|
||||
}
|
||||
|
||||
let bodyString = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)"
|
||||
logger.debug("POST /auth/token — status \(httpResponse.statusCode), body: \(bodyString)")
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorMessage = errorJson["error"] as? String {
|
||||
logger.error("POST /auth/token — server error: \(errorMessage)")
|
||||
throw AuthError.failed(errorMessage)
|
||||
}
|
||||
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode)")
|
||||
logger.error("POST /auth/token — unexpected status \(httpResponse.statusCode): \(bodyString)")
|
||||
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode): \(bodyString)")
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let logger = Logger(subsystem: "com.timetracker.app", category: "APIClient")
|
||||
|
||||
actor APIClient {
|
||||
private let session: URLSession
|
||||
@@ -43,9 +46,11 @@ actor APIClient {
|
||||
if authenticated {
|
||||
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||
guard let token = token else {
|
||||
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
@@ -53,27 +58,29 @@ actor APIClient {
|
||||
}
|
||||
|
||||
do {
|
||||
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
let message = try? decoder.decode(ErrorResponse.self, from: data).error
|
||||
await MainActor.run {
|
||||
AuthManager.shared.clearAuth()
|
||||
}
|
||||
throw NetworkError.httpError(statusCode: 401, message: message)
|
||||
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
let message = try? decoder.decode(ErrorResponse.self, from: data).error
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message)
|
||||
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||
}
|
||||
|
||||
if data.isEmpty {
|
||||
let empty: T? = nil
|
||||
return try decoder.decode(T.self, from: "{}".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
@@ -81,8 +88,10 @@ actor APIClient {
|
||||
} catch let error as NetworkError {
|
||||
throw error
|
||||
} catch let error as DecodingError {
|
||||
logger.error("\(method.rawValue) \(endpoint) — decoding error: \(error)")
|
||||
throw NetworkError.decodingError(error)
|
||||
} catch {
|
||||
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||
throw NetworkError.networkError(error)
|
||||
}
|
||||
}
|
||||
@@ -115,9 +124,11 @@ actor APIClient {
|
||||
if authenticated {
|
||||
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||
guard let token = token else {
|
||||
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
@@ -125,27 +136,31 @@ actor APIClient {
|
||||
}
|
||||
|
||||
do {
|
||||
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
let message = try? decoder.decode(ErrorResponse.self, from: data).error
|
||||
await MainActor.run {
|
||||
AuthManager.shared.clearAuth()
|
||||
}
|
||||
throw NetworkError.httpError(statusCode: 401, message: message)
|
||||
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
let message = try? decoder.decode(ErrorResponse.self, from: data).error
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message)
|
||||
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||
}
|
||||
} catch let error as NetworkError {
|
||||
throw error
|
||||
} catch {
|
||||
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||
throw NetworkError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user