Add detailed logging to auth flow on backend and iOS

This commit is contained in:
2026-02-19 18:55:00 +01:00
parent f1f60ef685
commit 1aac76af4a
4 changed files with 75 additions and 20 deletions

View File

@@ -8,8 +8,11 @@ export async function requireAuth(
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<void> { ): Promise<void> {
const tag = `[requireAuth] ${req.method} ${req.path}`;
// 1. Session-based auth (web frontend) // 1. Session-based auth (web frontend)
if (req.session?.user) { if (req.session?.user) {
console.log(`${tag} -> session auth OK (user: ${req.session.user.id})`);
req.user = req.session.user as AuthenticatedUser; req.user = req.session.user as AuthenticatedUser;
return next(); return next();
} }
@@ -18,17 +21,24 @@ export async function requireAuth(
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7); const token = authHeader.slice(7);
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
try { try {
// Verify the backend-signed JWT locally — no IDP network call needed.
req.user = verifyBackendJwt(token); req.user = verifyBackendJwt(token);
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);
return next(); return next();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`${tag} -> JWT verification failed: ${message}`);
res.status(401).json({ error: `Unauthorized: ${message}` }); res.status(401).json({ error: `Unauthorized: ${message}` });
return; return;
} }
} }
if (authHeader) {
console.warn(`${tag} -> Authorization header present but not a Bearer token: "${authHeader.slice(0, 30)}…"`);
} else {
console.warn(`${tag} -> No session and no Authorization header`);
}
res.status(401).json({ error: 'Unauthorized' }); res.status(401).json({ error: 'Unauthorized' });
} }

View File

@@ -50,21 +50,24 @@ router.get("/login", async (req, res) => {
await ensureOIDC(); await ensureOIDC();
const redirectUri = req.query.redirect_uri as string | undefined; const redirectUri = req.query.redirect_uri as string | undefined;
console.log(`[auth/login] initiated (redirect_uri: ${redirectUri ?? '(web flow)'})`);
const session = createAuthSession(redirectUri); const session = createAuthSession(redirectUri);
if (redirectUri) { if (redirectUri) {
// Native app flow: store session by state so /auth/token can retrieve it // Native app flow: store session by state so /auth/token can retrieve it
// without relying on the browser cookie jar. // without relying on the browser cookie jar.
storeNativeSession(session); storeNativeSession(session);
console.log(`[auth/login] native session stored (state: ${session.state})`);
} else { } else {
// Web flow: store session in the cookie-backed server session as before. // Web flow: store session in the cookie-backed server session as before.
req.session.oidc = session; req.session.oidc = session;
} }
const authorizationUrl = getAuthorizationUrl(session, redirectUri); const authorizationUrl = getAuthorizationUrl(session, redirectUri);
console.log(`[auth/login] redirecting to IDP`);
res.redirect(authorizationUrl); res.redirect(authorizationUrl);
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error("[auth/login] error:", error);
res.status(500).json({ error: "Failed to initiate login" }); res.status(500).json({ error: "Failed to initiate login" });
} }
}); });
@@ -129,24 +132,33 @@ router.post("/token", async (req, res) => {
await ensureOIDC(); await ensureOIDC();
const { code, state, redirect_uri } = req.body; const { code, state, redirect_uri } = req.body;
console.log(`[auth/token] received (state: ${state}, redirect_uri: ${redirect_uri}, code present: ${!!code})`);
if (!code || !state || !redirect_uri) { if (!code || !state || !redirect_uri) {
res.status(400).json({ error: "Missing required parameters: code, state, redirect_uri" }); const missing = ['code', 'state', 'redirect_uri'].filter(k => !req.body[k]);
console.warn(`[auth/token] missing parameters: ${missing.join(', ')}`);
res.status(400).json({ error: `Missing required parameters: ${missing.join(', ')}` });
return; return;
} }
const oidcSession = popNativeSession(state); const oidcSession = popNativeSession(state);
if (!oidcSession) { if (!oidcSession) {
console.warn(`[auth/token] no session found for state "${state}" — known states: [${[...nativeOidcSessions.keys()].join(', ')}]`);
res.status(400).json({ error: "OIDC session not found or expired. Initiate login again." }); res.status(400).json({ error: "OIDC session not found or expired. Initiate login again." });
return; return;
} }
console.log(`[auth/token] session found, exchanging code with IDP`);
const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri); const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri);
console.log(`[auth/token] IDP code exchange OK (access_token present: ${!!tokenSet.access_token}, id_token present: ${!!tokenSet.id_token})`);
const user = await getUserInfo(tokenSet); const user = await getUserInfo(tokenSet);
console.log(`[auth/token] user resolved (id: ${user.id}, email: ${user.email})`);
await syncUser(user); await syncUser(user);
// Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>. // Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>.
const backendJwt = signBackendJwt(user); const backendJwt = signBackendJwt(user);
console.log(`[auth/token] backend JWT minted for user ${user.id}`);
res.json({ res.json({
access_token: backendJwt, access_token: backendJwt,
@@ -156,7 +168,7 @@ router.post("/token", async (req, res) => {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error("Token exchange error:", error); console.error("[auth/token] error:", error);
res.status(500).json({ error: `Failed to exchange token: ${message}` }); res.status(500).json({ error: `Failed to exchange token: ${message}` });
} }
}); });

View File

@@ -1,6 +1,9 @@
import Foundation import Foundation
import AuthenticationServices import AuthenticationServices
import CryptoKit import CryptoKit
import OSLog
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthService")
final class AuthService: NSObject { final class AuthService: NSObject {
static let shared = AuthService() static let shared = AuthService()
@@ -29,6 +32,9 @@ final class AuthService: NSObject {
throw AuthError.invalidURL 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" let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
// Use an ephemeral session we only need the redirect URL back with the // 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 { if let error = error {
let authError: AuthError let authError: AuthError
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
logger.info("Login cancelled by user")
authError = .cancelled authError = .cancelled
} else { } else {
logger.error("ASWebAuthenticationSession error: \(error)")
authError = .failed(error.localizedDescription) authError = .failed(error.localizedDescription)
} }
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -55,6 +63,7 @@ final class AuthService: NSObject {
} }
guard let callbackURL = callbackURL else { guard let callbackURL = callbackURL else {
logger.error("ASWebAuthenticationSession returned nil callbackURL")
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .authError, name: .authError,
@@ -91,6 +100,7 @@ final class AuthService: NSObject {
let code = components.queryItems?.first(where: { $0.name == "code" })?.value, let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
let state = components.queryItems?.first(where: { $0.name == "state" })?.value let state = components.queryItems?.first(where: { $0.name == "state" })?.value
else { else {
logger.error("Callback URL missing code or state: \(url.absoluteString)")
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .authError, name: .authError,
@@ -103,11 +113,13 @@ final class AuthService: NSObject {
Task { Task {
do { do {
logger.info("Exchanging code for tokens (state: \(state), redirect_uri: \(AppConfig.authCallbackURL))")
let tokenResponse = try await exchangeCodeForTokens( let tokenResponse = try await exchangeCodeForTokens(
code: code, code: code,
state: state, state: state,
redirectUri: AppConfig.authCallbackURL redirectUri: AppConfig.authCallbackURL
) )
logger.info("Token exchange succeeded for user: \(tokenResponse.user.id)")
await AuthManager.shared.handleTokenResponse(tokenResponse) await AuthManager.shared.handleTokenResponse(tokenResponse)
@@ -115,6 +127,7 @@ final class AuthService: NSObject {
NotificationCenter.default.post(name: .authCallbackReceived, object: nil) NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
} }
} catch { } catch {
logger.error("Token exchange failed: \(error)")
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .authError, name: .authError,
@@ -152,12 +165,17 @@ final class AuthService: NSObject {
throw AuthError.failed("Invalid response") 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 { guard httpResponse.statusCode == 200 else {
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any], if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorMessage = errorJson["error"] as? String { let errorMessage = errorJson["error"] as? String {
logger.error("POST /auth/token — server error: \(errorMessage)")
throw AuthError.failed(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) return try JSONDecoder().decode(TokenResponse.self, from: data)

View File

@@ -1,4 +1,7 @@
import Foundation import Foundation
import OSLog
private let logger = Logger(subsystem: "com.timetracker.app", category: "APIClient")
actor APIClient { actor APIClient {
private let session: URLSession private let session: URLSession
@@ -43,9 +46,11 @@ actor APIClient {
if authenticated { if authenticated {
let token = await MainActor.run { AuthManager.shared.accessToken } let token = await MainActor.run { AuthManager.shared.accessToken }
guard let token = token else { guard let token = token else {
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
throw NetworkError.unauthorized throw NetworkError.unauthorized
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
} }
if let body = body { if let body = body {
@@ -53,27 +58,29 @@ actor APIClient {
} }
do { do {
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
let message = try? decoder.decode(ErrorResponse.self, from: data).error let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
await MainActor.run { logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
AuthManager.shared.clearAuth() await MainActor.run { AuthManager.shared.clearAuth() }
} throw NetworkError.httpError(statusCode: 401, message: serverMessage)
throw NetworkError.httpError(statusCode: 401, message: message)
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {
let message = try? decoder.decode(ErrorResponse.self, from: data).error let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message) logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
} }
if data.isEmpty { if data.isEmpty {
let empty: T? = nil
return try decoder.decode(T.self, from: "{}".data(using: .utf8)!) return try decoder.decode(T.self, from: "{}".data(using: .utf8)!)
} }
@@ -81,8 +88,10 @@ actor APIClient {
} catch let error as NetworkError { } catch let error as NetworkError {
throw error throw error
} catch let error as DecodingError { } catch let error as DecodingError {
logger.error("\(method.rawValue) \(endpoint) — decoding error: \(error)")
throw NetworkError.decodingError(error) throw NetworkError.decodingError(error)
} catch { } catch {
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
throw NetworkError.networkError(error) throw NetworkError.networkError(error)
} }
} }
@@ -115,9 +124,11 @@ actor APIClient {
if authenticated { if authenticated {
let token = await MainActor.run { AuthManager.shared.accessToken } let token = await MainActor.run { AuthManager.shared.accessToken }
guard let token = token else { guard let token = token else {
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
throw NetworkError.unauthorized throw NetworkError.unauthorized
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
} }
if let body = body { if let body = body {
@@ -125,27 +136,31 @@ actor APIClient {
} }
do { do {
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
let message = try? decoder.decode(ErrorResponse.self, from: data).error let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
await MainActor.run { logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
AuthManager.shared.clearAuth() await MainActor.run { AuthManager.shared.clearAuth() }
} throw NetworkError.httpError(statusCode: 401, message: serverMessage)
throw NetworkError.httpError(statusCode: 401, message: message)
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {
let message = try? decoder.decode(ErrorResponse.self, from: data).error let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message) 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 { } catch let error as NetworkError {
throw error throw error
} catch { } catch {
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
throw NetworkError.networkError(error) throw NetworkError.networkError(error)
} }
} }