This commit is contained in:
2026-02-18 22:58:41 +01:00
parent b3db7cbd7b
commit 1ca76b0fec
5 changed files with 41 additions and 17 deletions

View File

@@ -170,10 +170,27 @@ export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
} }
} }
// Cache userinfo responses to avoid hitting the OIDC provider on every request.
// Entries expire after 5 minutes. The cache is keyed by the raw access token.
const userinfoCache = new Map<string, { user: AuthenticatedUser; expiresAt: number }>();
const USERINFO_CACHE_TTL_MS = 5 * 60 * 1000;
export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> { export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> {
const cached = userinfoCache.get(accessToken);
if (cached && Date.now() < cached.expiresAt) {
return cached.user;
}
const client = getOIDCClient(); const client = getOIDCClient();
const userInfo = await client.userinfo(accessToken); let userInfo: Awaited<ReturnType<typeof client.userinfo>>;
try {
userInfo = await client.userinfo(accessToken);
} catch (err) {
// Remove any stale cache entry for this token
userinfoCache.delete(accessToken);
throw err;
}
const id = String(userInfo.sub); const id = String(userInfo.sub);
const username = String(userInfo.preferred_username || userInfo.name || id); const username = String(userInfo.preferred_username || userInfo.name || id);
@@ -184,5 +201,7 @@ export async function verifyBearerToken(accessToken: string): Promise<Authentica
throw new Error('Email not provided by OIDC provider'); throw new Error('Email not provided by OIDC provider');
} }
return { id, username, fullName, email }; const user: AuthenticatedUser = { id, username, fullName, email };
userinfoCache.set(accessToken, { user, expiresAt: Date.now() + USERINFO_CACHE_TTL_MS });
return user;
} }

View File

@@ -16,14 +16,17 @@ export async function requireAuth(
// 2. Bearer token auth (iOS / native clients) // 2. Bearer token auth (iOS / native clients)
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
console.log('[requireAuth] authorization header:', authHeader ? `${authHeader.slice(0, 20)}` : '(none)');
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const accessToken = authHeader.slice(7); const accessToken = authHeader.slice(7);
try { try {
const user = await verifyBearerToken(accessToken); const user = await verifyBearerToken(accessToken);
req.user = user; req.user = user;
return next(); return next();
} catch { } catch (err) {
res.status(401).json({ error: 'Unauthorized' }); const message = err instanceof Error ? err.message : String(err);
console.error('[requireAuth] verifyBearerToken failed:', err);
res.status(401).json({ error: `Unauthorized: ${message}` });
return; return;
} }
} }

View File

@@ -198,7 +198,7 @@ extension Notification.Name {
struct TokenResponse: Codable { struct TokenResponse: Codable {
let accessToken: String let accessToken: String
let idToken: String let idToken: String?
let tokenType: String let tokenType: String
let expiresIn: Int? let expiresIn: Int?
let user: User let user: User

View File

@@ -60,10 +60,11 @@ actor APIClient {
} }
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
let message = try? decoder.decode(ErrorResponse.self, from: data).error
await MainActor.run { await MainActor.run {
AuthManager.shared.clearAuth() AuthManager.shared.clearAuth()
} }
throw NetworkError.unauthorized throw NetworkError.httpError(statusCode: 401, message: message)
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {
@@ -131,10 +132,11 @@ actor APIClient {
} }
if httpResponse.statusCode == 401 { if httpResponse.statusCode == 401 {
let message = try? decoder.decode(ErrorResponse.self, from: data).error
await MainActor.run { await MainActor.run {
AuthManager.shared.clearAuth() AuthManager.shared.clearAuth()
} }
throw NetworkError.unauthorized throw NetworkError.httpError(statusCode: 401, message: message)
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {

View File

@@ -9,22 +9,22 @@ enum APIEndpoint {
static let me = "/auth/me" static let me = "/auth/me"
// Clients // Clients
static let clients = "/api/clients" static let clients = "/clients"
static func client(id: String) -> String { "/api/clients/\(id)" } static func client(id: String) -> String { "/clients/\(id)" }
// Projects // Projects
static let projects = "/api/projects" static let projects = "/projects"
static func project(id: String) -> String { "/api/projects/\(id)" } static func project(id: String) -> String { "/projects/\(id)" }
// Time Entries // Time Entries
static let timeEntries = "/api/time-entries" static let timeEntries = "/time-entries"
static let timeEntriesStatistics = "/api/time-entries/statistics" static let timeEntriesStatistics = "/time-entries/statistics"
static func timeEntry(id: String) -> String { "/api/time-entries/\(id)" } static func timeEntry(id: String) -> String { "/time-entries/\(id)" }
// Timer // Timer
static let timer = "/api/timer" static let timer = "/timer"
static let timerStart = "/api/timer/start" static let timerStart = "/timer/start"
static let timerStop = "/api/timer/stop" static let timerStop = "/timer/stop"
} }
struct APIEndpoints { struct APIEndpoints {