Replace IDP token passthrough with backend-issued JWT for iOS auth

iOS clients now exchange the OIDC authorization code for a backend-signed
HS256 JWT via POST /auth/token. All subsequent API requests authenticate
using this JWT as a Bearer token, verified locally — no per-request IDP
call is needed. Web frontend session-cookie auth is unchanged.
This commit is contained in:
2026-02-19 18:45:03 +01:00
parent 1ca76b0fec
commit 946cd35832
10 changed files with 662 additions and 85 deletions

View File

@@ -11,6 +11,7 @@ final class AuthManager: ObservableObject {
private let keychain: Keychain
private let apiClient = APIClient()
/// The backend-issued JWT. Sent as `Authorization: Bearer <token>` on every API call.
var accessToken: String? {
get { try? keychain.get(AppConstants.KeychainKeys.accessToken) }
set {
@@ -22,17 +23,6 @@ final class AuthManager: ObservableObject {
}
}
var idToken: String? {
get { try? keychain.get(AppConstants.KeychainKeys.idToken) }
set {
if let value = newValue {
try? keychain.set(value, key: AppConstants.KeychainKeys.idToken)
} else {
try? keychain.remove(AppConstants.KeychainKeys.idToken)
}
}
}
private init() {
self.keychain = Keychain(service: "com.timetracker.app")
.accessibility(.whenUnlockedThisDeviceOnly)
@@ -66,7 +56,9 @@ final class AuthManager: ObservableObject {
}
func logout() async throws {
try await apiClient.requestVoid(
// Best-effort server-side logout; the backend JWT is stateless so the
// real security comes from clearing the local token.
try? await apiClient.requestVoid(
endpoint: APIEndpoint.logout,
method: .post,
authenticated: true
@@ -76,14 +68,12 @@ final class AuthManager: ObservableObject {
func clearAuth() {
accessToken = nil
idToken = nil
currentUser = nil
isAuthenticated = false
}
func handleTokenResponse(_ response: TokenResponse) async {
accessToken = response.accessToken
idToken = response.idToken
currentUser = response.user
isAuthenticated = true
}

View File

@@ -31,8 +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.
// Use an ephemeral session we only need the redirect URL back with the
// authorization code; no cookies or shared state are needed.
let webAuthSession = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: callbackScheme
@@ -69,9 +69,8 @@ final class AuthService: NSObject {
}
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
// Ephemeral session: no shared cookies or browsing data with Safari.
webAuthSession.prefersEphemeralWebBrowserSession = true
self.authSession = webAuthSession
@@ -138,8 +137,8 @@ final class AuthService: NSObject {
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// state is sent so the backend can look up and validate the original session.
// code_verifier is not sent the backend uses its own verifier from the session.
// state is sent so the backend can look up the original PKCE session.
// code_verifier is NOT sent the backend holds it in the in-memory session.
let body: [String: Any] = [
"code": code,
"state": state,
@@ -198,14 +197,12 @@ extension Notification.Name {
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

@@ -13,7 +13,6 @@ enum AppConstants {
enum KeychainKeys {
static let accessToken = "accessToken"
static let idToken = "idToken"
}
}