This commit is contained in:
2026-02-18 22:37:49 +01:00
parent ed180500a6
commit 7e8e220e3b
20 changed files with 320 additions and 148 deletions

View File

@@ -8,9 +8,14 @@ export async function initializeOIDC(): Promise<void> {
try {
const issuer = await Issuer.discover(config.oidc.issuerUrl);
const redirectUris = [config.oidc.redirectUri];
if (config.oidc.iosRedirectUri) {
redirectUris.push(config.oidc.iosRedirectUri);
}
oidcClient = new issuer.Client({
client_id: config.oidc.clientId,
redirect_uris: [config.oidc.redirectUri],
redirect_uris: redirectUris,
response_types: ['code'],
token_endpoint_auth_method: 'none', // PKCE flow - no client secret
});
@@ -33,27 +38,35 @@ export interface AuthSession {
codeVerifier: string;
state: string;
nonce: string;
redirectUri?: string;
}
export function createAuthSession(): AuthSession {
export function createAuthSession(redirectUri?: string): AuthSession {
return {
codeVerifier: generators.codeVerifier(),
state: generators.state(),
nonce: generators.nonce(),
redirectUri,
};
}
export function getAuthorizationUrl(session: AuthSession): string {
export function getAuthorizationUrl(session: AuthSession, redirectUri?: string): string {
const client = getOIDCClient();
const codeChallenge = generators.codeChallenge(session.codeVerifier);
return client.authorizationUrl({
const params: Record<string, string> = {
scope: 'openid profile email',
state: session.state,
nonce: session.nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
};
if (redirectUri) {
params.redirect_uri = redirectUri;
}
return client.authorizationUrl(params);
}
export async function handleCallback(
@@ -62,8 +75,10 @@ export async function handleCallback(
): Promise<TokenSet> {
const client = getOIDCClient();
const redirectUri = session.redirectUri || config.oidc.redirectUri;
const tokenSet = await client.callback(
config.oidc.redirectUri,
redirectUri,
params,
{
code_verifier: session.codeVerifier,
@@ -114,4 +129,21 @@ export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
} catch {
return false;
}
}
export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> {
const client = getOIDCClient();
const userInfo = await client.userinfo(accessToken);
const id = String(userInfo.sub);
const username = String(userInfo.preferred_username || userInfo.name || id);
const email = String(userInfo.email || '');
const fullName = String(userInfo.name || '') || null;
if (!email) {
throw new Error('Email not provided by OIDC provider');
}
return { id, username, fullName, email };
}

View File

@@ -17,6 +17,7 @@ export const config = {
redirectUri:
process.env.OIDC_REDIRECT_URI ||
"http://localhost:3001/api/auth/callback",
iosRedirectUri: process.env.OIDC_IOS_REDIRECT_URI || "timetracker://oauth/callback",
},
session: {

View File

@@ -1,19 +1,34 @@
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { getOIDCClient, verifyBearerToken } from '../auth/oidc';
export function requireAuth(
export async function requireAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
if (!req.session?.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
): Promise<void> {
// 1. Session-based auth (web frontend)
if (req.session?.user) {
req.user = req.session.user as AuthenticatedUser;
return next();
}
req.user = req.session.user as AuthenticatedUser;
next();
// 2. Bearer token auth (iOS / native clients)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const accessToken = authHeader.slice(7);
try {
const user = await verifyBearerToken(accessToken);
req.user = user;
return next();
} catch {
res.status(401).json({ error: 'Unauthorized' });
return;
}
}
res.status(401).json({ error: 'Unauthorized' });
}
export function optionalAuth(
@@ -42,4 +57,4 @@ export async function syncUser(user: AuthenticatedUser): Promise<void> {
email: user.email,
},
});
}
}

View File

@@ -26,10 +26,11 @@ router.get("/login", async (req, res) => {
try {
await ensureOIDC();
const session = createAuthSession();
const redirectUri = req.query.redirect_uri as string | undefined;
const session = createAuthSession(redirectUri);
req.session.oidc = session;
const authorizationUrl = getAuthorizationUrl(session);
const authorizationUrl = getAuthorizationUrl(session, redirectUri);
res.redirect(authorizationUrl);
} catch (error) {
console.error("Login error:", error);
@@ -88,4 +89,60 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
res.json(req.user);
});
// POST /auth/token - Exchange authorization code for tokens (for native apps)
// The session cookie set during /auth/login is sent automatically by ASWebAuthenticationSession,
// so req.session.oidc contains the original state/nonce for validation.
router.post("/token", async (req, res) => {
try {
await ensureOIDC();
const { code, state, code_verifier, redirect_uri } = req.body;
if (!code || !state || !code_verifier || !redirect_uri) {
res.status(400).json({ error: "Missing required parameters: code, state, code_verifier, redirect_uri" });
return;
}
const oidcSession = req.session.oidc;
if (!oidcSession) {
res.status(400).json({ error: "No active OIDC session. Initiate login first." });
return;
}
// Validate that the returned state matches what was stored in the session
if (oidcSession.state !== state) {
res.status(400).json({ error: "State mismatch" });
return;
}
// Use the session's own codeVerifier (generated by the backend during /auth/login)
// rather than a client-supplied one, to prevent verifier substitution attacks.
const session = {
codeVerifier: oidcSession.codeVerifier,
state: oidcSession.state,
nonce: oidcSession.nonce,
redirectUri: redirect_uri,
};
const tokenSet = await handleCallback({ code, state }, session);
const user = await getUserInfo(tokenSet);
await syncUser(user);
// Clear OIDC session state now that it has been consumed
delete req.session.oidc;
res.json({
access_token: tokenSet.access_token,
id_token: tokenSet.id_token,
token_type: tokenSet.token_type,
expires_in: tokenSet.expires_in,
user,
});
} catch (error) {
console.error("Token exchange error:", error);
res.status(500).json({ error: "Failed to exchange token" });
}
});
export default router;

View File

@@ -81,6 +81,13 @@ final class AuthManager: ObservableObject {
isAuthenticated = false
}
func handleTokenResponse(_ response: TokenResponse) async {
accessToken = response.accessToken
idToken = response.idToken
currentUser = response.user
isAuthenticated = true
}
var loginURL: URL {
APIEndpoints.url(for: APIEndpoint.login)
}

View File

@@ -15,21 +15,13 @@ final class AuthService: NSObject {
func login(presentationAnchor: ASPresentationAnchor?) async throws {
self.presentationAnchor = presentationAnchor
let codeVerifier = generateCodeVerifier()
let codeChallenge = generateCodeChallenge(from: codeVerifier)
let session = UUID().uuidString
UserDefaults.standard.set(codeVerifier, forKey: "oidc_code_verifier_\(session)")
// Only the redirect_uri is needed the backend owns PKCE generation.
var components = URLComponents(
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
resolvingAgainstBaseURL: true
)
components?.queryItems = [
URLQueryItem(name: "session", value: session),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
]
@@ -39,6 +31,8 @@ final class AuthService: NSObject {
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
// Use a shared (non-ephemeral) session so the backend session cookie set during
// /auth/login is automatically included in the /auth/token POST.
let webAuthSession = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: callbackScheme
@@ -71,10 +65,12 @@ final class AuthService: NSObject {
return
}
self?.handleCallback(url: callbackURL, session: session)
self?.handleCallback(url: callbackURL)
}
webAuthSession.presentationContextProvider = self
// prefersEphemeralWebBrowserSession = false ensures the session cookie from
// /auth/login is retained and sent with the subsequent /auth/token request.
webAuthSession.prefersEphemeralWebBrowserSession = false
self.authSession = webAuthSession
@@ -91,42 +87,83 @@ final class AuthService: NSObject {
}
}
private func handleCallback(url: URL, session: String) {
private func handleCallback(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
else {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .authError,
object: nil,
userInfo: ["error": AuthError.noCallback]
)
}
return
}
let codeVerifier = UserDefaults.standard.string(forKey: "oidc_code_verifier_\(session)")
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .authCallbackReceived,
object: nil,
userInfo: [
"code": code,
"codeVerifier": codeVerifier ?? ""
]
)
Task {
do {
let tokenResponse = try await exchangeCodeForTokens(
code: code,
state: state,
redirectUri: AppConfig.authCallbackURL
)
await AuthManager.shared.handleTokenResponse(tokenResponse)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
}
} catch {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .authError,
object: nil,
userInfo: ["error": AuthError.failed(error.localizedDescription)]
)
}
}
}
}
private func generateCodeVerifier() -> String {
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateCodeChallenge(from verifier: String) -> String {
guard let data = verifier.data(using: .ascii) else { return "" }
let hash = SHA256.hash(data: data)
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
private func exchangeCodeForTokens(
code: String,
state: String,
redirectUri: String
) async throws -> TokenResponse {
let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// code_verifier is intentionally omitted the backend uses its own verifier
// that was generated during /auth/login and stored in the server-side session.
// state is sent so the backend can look up and validate the original session.
let body: [String: Any] = [
"code": code,
"state": state,
"code_verifier": "", // kept for API compatibility; backend ignores it
"redirect_uri": redirectUri
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthError.failed("Invalid response")
}
guard httpResponse.statusCode == 200 else {
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorMessage = errorJson["error"] as? String {
throw AuthError.failed(errorMessage)
}
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode)")
}
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
}
@@ -160,3 +197,19 @@ extension Notification.Name {
static let authCallbackReceived = Notification.Name("authCallbackReceived")
static let authError = Notification.Name("authError")
}
struct TokenResponse: Codable {
let accessToken: String
let idToken: String
let tokenType: String
let expiresIn: Int?
let user: User
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case idToken = "id_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
case user
}
}

View File

@@ -41,7 +41,8 @@ actor APIClient {
request.setValue("application/json", forHTTPHeaderField: "Accept")
if authenticated {
guard let token = AuthManager.shared.accessToken else {
let token = await MainActor.run { AuthManager.shared.accessToken }
guard let token = token else {
throw NetworkError.unauthorized
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@@ -111,7 +112,8 @@ actor APIClient {
request.setValue("application/json", forHTTPHeaderField: "Accept")
if authenticated {
guard let token = AuthManager.shared.accessToken else {
let token = await MainActor.run { AuthManager.shared.accessToken }
guard let token = token else {
throw NetworkError.unauthorized
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

View File

@@ -4,6 +4,7 @@ enum APIEndpoint {
// Auth
static let login = "/auth/login"
static let callback = "/auth/callback"
static let token = "/auth/token"
static let logout = "/auth/logout"
static let me = "/auth/me"

View File

@@ -22,7 +22,7 @@ final class SyncManager: ObservableObject {
DispatchQueue.main.async {
self?.isOnline = path.status == .satisfied
if path.status == .satisfied {
self?.syncPendingChanges()
Task { await self?.syncPendingChanges() }
}
}
}

View File

@@ -81,11 +81,9 @@ struct LoginView: View {
}
private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) {
Task {
await authManager.checkAuthState()
await MainActor.run {
isLoading = false
}
}
// AuthManager.handleTokenResponse() already set isAuthenticated = true
// and populated currentUser during the token exchange in AuthService.
// No further network call is needed here.
isLoading = false
}
}

View File

@@ -38,7 +38,7 @@ final class ClientsViewModel: ObservableObject {
do {
let input = CreateClientInput(name: name, description: description)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.clients,
method: .post,
body: input,
@@ -57,7 +57,7 @@ final class ClientsViewModel: ObservableObject {
do {
let input = UpdateClientInput(name: name, description: description)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.client(id: id),
method: .put,
body: input,

View File

@@ -50,7 +50,7 @@ final class ProjectsViewModel: ObservableObject {
color: color,
clientId: clientId
)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.projects,
method: .post,
body: input,
@@ -74,7 +74,7 @@ final class ProjectsViewModel: ObservableObject {
color: color,
clientId: clientId
)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.project(id: id),
method: .put,
body: input,

View File

@@ -79,7 +79,7 @@ struct TimeEntryRow: View {
}
HStack {
Text(formatDateRange(entry.startTime, entry.endTime))
Text(formatDateRange(start: entry.startTime, end: entry.endTime))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()

View File

@@ -119,11 +119,11 @@ struct TimeEntryFormView: View {
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
minute: calendar.component(.minute, from: startTime),
second: 0,
on: startDate) ?? startDate
of: startDate) ?? startDate
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
minute: calendar.component(.minute, from: endTime),
second: 0,
on: endDate) ?? endDate
of: endDate) ?? endDate
Task {
do {
@@ -134,7 +134,7 @@ struct TimeEntryFormView: View {
description: description.isEmpty ? nil : description,
projectId: project.id
)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
method: .put,
body: input,
@@ -147,7 +147,7 @@ struct TimeEntryFormView: View {
description: description.isEmpty ? nil : description,
projectId: project.id
)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntries,
method: .post,
body: input,
@@ -161,9 +161,10 @@ struct TimeEntryFormView: View {
onSave()
}
} catch {
let errorMessage = error.localizedDescription
await MainActor.run {
isLoading = false
error = error.localizedDescription
self.error = errorMessage
}
}
}

View File

@@ -44,11 +44,16 @@ struct TimerView: View {
.font(.system(size: 64, weight: .light, design: .monospaced))
.foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary)
if let project = viewModel.selectedProject ?? viewModel.activeTimer?.project {
if let project = viewModel.selectedProject {
ProjectColorBadge(
color: project.color,
name: project.name
)
} else if let timerProject = viewModel.activeTimer?.project {
ProjectColorBadge(
color: timerProject.color,
name: timerProject.name
)
} else {
Text("No project selected")
.font(.subheadline)
@@ -137,7 +142,7 @@ struct ProjectPickerSheet: View {
.foregroundStyle(.primary)
if selectedProject == nil {
Image(systemName: "checkmark")
.foregroundStyle(.accent)
.foregroundStyle(Color.accentColor)
}
}
}
@@ -155,7 +160,7 @@ struct ProjectPickerSheet: View {
.foregroundStyle(.secondary)
if selectedProject?.id == project.id {
Image(systemName: "checkmark")
.foregroundStyle(.accent)
.foregroundStyle(Color.accentColor)
}
}
}

View File

@@ -102,7 +102,7 @@ final class TimerViewModel: ObservableObject {
do {
let input = StopTimerInput(projectId: projectId)
_ = try await apiClient.request(
try await apiClient.requestVoid(
endpoint: APIEndpoint.timerStop,
method: .post,
body: input,

View File

@@ -2,65 +2,65 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.timetracker.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timetracker</string>
</array>
</dict>
</array>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>API_BASE_URL</key>
<string>https://timetracker.simon-franken.de/api</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.timetracker.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timetracker</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
import Foundation
struct Client: Codable, Identifiable, Equatable {
struct Client: Codable, Identifiable, Equatable, Hashable {
let id: String
let name: String
let description: String?

View File

@@ -1,6 +1,6 @@
import Foundation
struct Project: Codable, Identifiable, Equatable {
struct Project: Codable, Identifiable, Equatable, Hashable {
let id: String
let name: String
let description: String?
@@ -11,7 +11,7 @@ struct Project: Codable, Identifiable, Equatable {
let updatedAt: String
}
struct ClientReference: Codable, Equatable {
struct ClientReference: Codable, Equatable, Hashable {
let id: String
let name: String
}

View File

@@ -39,12 +39,12 @@ struct Pagination: Codable, Equatable {
}
struct TimeEntryFilters: Codable {
let startDate: String?
let endDate: String?
let projectId: String?
let clientId: String?
let page: Int?
let limit: Int?
var startDate: String?
var endDate: String?
var projectId: String?
var clientId: String?
var page: Int?
var limit: Int?
init(
startDate: Date? = nil,