From 05cc4311a74a1b656cf99252dcc5f49baae6590b Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 18 May 2026 18:21:32 -0500 Subject: [PATCH] Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit honeyDue identity is now owned by Ory Kratos (auth.myhoneydue.com). The honeyDue Go API no longer does auth — authenticated API requests carry the Kratos session token on the X-Session-Token header (the old `Authorization: Token ` scheme is gone). What changed: - models/Kratos.kt (new): models for Kratos native (`api`) self-service flows — flow envelope (id + ui.action + ui.nodes/messages), login/ registration success bodies, OIDC/password/recovery/verification submit payloads, session + identity + traits. - ApiConfig.kt / ApiClient.kt: add getKratosBaseUrl() — LOCAL points at a localhost Kratos (:4433), DEV/PROD at auth.myhoneydue.com. Add the SESSION_TOKEN_HEADER ("X-Session-Token") constant and an authHeader() request extension. - AuthApi.kt: rewritten to drive Kratos native flows — login (GET .../self-service/login/api -> POST ui.action with method:password), registration (traits:{email,name{first,last}}), recovery + verification (method:code), Apple/Google via OIDC (method:oidc, provider, id_token). Kratos validation errors are pulled from ui.nodes[].messages / ui.messages. On success the Kratos session_token is resolved against honeyDue /auth/me (still session-token gated) to assemble AuthResponse. Public method signatures + return types are unchanged, so APILayer / AuthViewModel / UI / iOS Swift compile against the same ApiResult<...> shapes with no rework. - ApiClient.kt: the 401 handler now re-validates the Kratos session via /sessions/whoami instead of calling a (now-gone) refresh endpoint. TokenExpiredException is kept (messages updated). - All 10 honeyDue API clients + AuthenticatedImage + CoilAuthInterceptor: send X-Session-Token instead of Authorization: Token. CoilAuthInterceptor drops the authScheme prefix in favour of a configurable headerName. - iOS Swift: AuthenticatedImage / DocumentDetailView / PresignedUploader switched to the X-Session-Token header. iOS auth ViewModels keep native login/registration/recovery forms and need no other change because the Kotlin APILayer surface is identical — no browser redirect. - Tests: CoilAuthInterceptorTest rewritten for the X-Session-Token scheme; HttpClientPluginsTest TokenExpiredException assertions updated. Verified: :composeApp:compileDebugKotlinAndroid, :assembleDebug and :compileKotlinIosSimulatorArm64 all build; network/auth unit tests pass. iOS Swift not built here (no Xcode toolchain) but is correct by construction against the unchanged Kotlin API. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kotlin/com/tt/honeyDue/MainActivity.kt | 8 +- .../network/CoilAuthInterceptorTest.kt | 67 +- .../kotlin/com/tt/honeyDue/models/Kratos.kt | 235 ++++++ .../kotlin/com/tt/honeyDue/models/User.kt | 8 +- .../com/tt/honeyDue/network/APILayer.kt | 26 +- .../com/tt/honeyDue/network/ApiClient.kt | 137 +-- .../com/tt/honeyDue/network/ApiConfig.kt | 22 + .../kotlin/com/tt/honeyDue/network/AuthApi.kt | 782 +++++++++++++----- .../honeyDue/network/CoilAuthInterceptor.kt | 45 +- .../com/tt/honeyDue/network/ContractorApi.kt | 16 +- .../com/tt/honeyDue/network/DocumentApi.kt | 22 +- .../com/tt/honeyDue/network/LookupsApi.kt | 14 +- .../tt/honeyDue/network/NotificationApi.kt | 16 +- .../com/tt/honeyDue/network/ResidenceApi.kt | 32 +- .../tt/honeyDue/network/SubscriptionApi.kt | 14 +- .../kotlin/com/tt/honeyDue/network/TaskApi.kt | 20 +- .../tt/honeyDue/network/TaskCompletionApi.kt | 10 +- .../tt/honeyDue/network/TaskTemplateApi.kt | 2 +- .../com/tt/honeyDue/network/UploadApi.kt | 2 +- .../ui/components/AuthenticatedImage.kt | 5 +- .../honeyDue/network/HttpClientPluginsTest.kt | 18 +- .../Components/AuthenticatedImage.swift | 6 +- .../iosApp/Documents/DocumentDetailView.swift | 5 +- iosApp/iosApp/Helpers/PresignedUploader.swift | 3 +- 24 files changed, 1105 insertions(+), 410 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt index 3b63fda..fb7a584 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt @@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory { return ImageLoader.Builder(context) .components { // Auth interceptor runs before the network fetcher so every - // image request carries the current Authorization header, with - // 401 -> refresh-token -> retry handled transparently. Mirrors - // iOS AuthenticatedImage.swift (Stream U). + // image request carries the current X-Session-Token header + // (Kratos session token), with 401 -> session re-check -> + // retry handled transparently. Mirrors iOS + // AuthenticatedImage.swift. add( CoilAuthInterceptor( tokenProvider = { TokenStorage.getToken() }, @@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory { val r = APILayer.refreshToken() if (r is ApiResult.Success) r.data else null }, - authScheme = "Token", ) ) add(KtorNetworkFetcherFactory()) diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt index 348fce7..90b4f9b 100644 --- a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt @@ -25,10 +25,14 @@ import kotlin.test.assertTrue /** * Unit tests for [CoilAuthInterceptor]. * + * Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on + * the Kratos session token, carried on the `X-Session-Token` header (the old + * `Authorization: Token …` scheme is gone). + * * The interceptor is responsible for: - * 1. Attaching `Authorization: ` to image requests. - * 2. On HTTP 401, calling the refresh callback once and retrying the - * request with the new token. + * 1. Attaching `X-Session-Token: ` to image requests. + * 2. On HTTP 401, calling the re-validation callback once and retrying the + * request with the returned token. * 3. Not looping: if the retry also returns 401, the error is returned. * 4. When no token is available, the request proceeds unauthenticated. * @@ -96,7 +100,7 @@ class CoilAuthInterceptorTest { } @Test - fun interceptor_attaches_authorization_header_when_token_present() = runTest { + fun interceptor_attaches_session_token_header_when_token_present() = runTest { val request = makeRequest() val chain = FakeChain( initialRequest = request, @@ -105,7 +109,6 @@ class CoilAuthInterceptorTest { val interceptor = CoilAuthInterceptor( tokenProvider = { "abc123" }, refreshToken = { null }, - authScheme = "Token", ) val result = interceptor.intercept(chain) @@ -113,7 +116,9 @@ class CoilAuthInterceptorTest { assertTrue(result is SuccessResult, "Expected success result") assertEquals(1, chain.capturedRequests.size) val sent = chain.capturedRequests.first() - assertEquals("Token abc123", sent.httpHeaders["Authorization"]) + // Token is sent bare (no scheme prefix) on the X-Session-Token header. + assertEquals("abc123", sent.httpHeaders["X-Session-Token"]) + assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set") } @Test @@ -126,7 +131,6 @@ class CoilAuthInterceptorTest { val interceptor = CoilAuthInterceptor( tokenProvider = { null }, refreshToken = { null }, - authScheme = "Token", ) val result = interceptor.intercept(chain) @@ -134,12 +138,12 @@ class CoilAuthInterceptorTest { assertTrue(result is SuccessResult) assertEquals(1, chain.capturedRequests.size) val sent = chain.capturedRequests.first() - // No Authorization header should have been added - assertNull(sent.httpHeaders["Authorization"]) + // No session-token header should have been added. + assertNull(sent.httpHeaders["X-Session-Token"]) } @Test - fun interceptor_refreshes_and_retries_on_401() = runTest { + fun interceptor_revalidates_and_retries_on_401() = runTest { val request = makeRequest() var refreshCallCount = 0 val chain = FakeChain( @@ -150,25 +154,25 @@ class CoilAuthInterceptorTest { ) ) val interceptor = CoilAuthInterceptor( - tokenProvider = { "old-token" }, + tokenProvider = { "session-tok" }, refreshToken = { refreshCallCount++ - "new-token" + // Kratos session tokens are not rotated — same token echoed back. + "session-tok" }, - authScheme = "Token", ) val result = interceptor.intercept(chain) assertTrue(result is SuccessResult, "Expected retry to succeed") - assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once") + assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once") assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry") - assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"]) - assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"]) + assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"]) + assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"]) } @Test - fun interceptor_returns_error_when_refresh_returns_null() = runTest { + fun interceptor_returns_error_when_revalidation_returns_null() = runTest { val request = makeRequest() var refreshCallCount = 0 val chain = FakeChain( @@ -176,19 +180,18 @@ class CoilAuthInterceptorTest { responses = mutableListOf({ req -> make401Error(req) }) ) val interceptor = CoilAuthInterceptor( - tokenProvider = { "old-token" }, + tokenProvider = { "session-tok" }, refreshToken = { refreshCallCount++ null }, - authScheme = "Token", ) val result = interceptor.intercept(chain) - assertTrue(result is ErrorResult, "Expected error result when refresh fails") - assertEquals(1, refreshCallCount, "refreshToken should be attempted once") - // Only the first attempt should have gone through + assertTrue(result is ErrorResult, "Expected error result when session is gone") + assertEquals(1, refreshCallCount, "session re-check should be attempted once") + // Only the first attempt should have gone through. assertEquals(1, chain.capturedRequests.size) } @@ -204,23 +207,22 @@ class CoilAuthInterceptorTest { ) ) val interceptor = CoilAuthInterceptor( - tokenProvider = { "old-token" }, + tokenProvider = { "session-tok" }, refreshToken = { refreshCallCount++ - "new-token" + "session-tok" }, - authScheme = "Token", ) val result = interceptor.intercept(chain) assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult") - assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop") + assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop") assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry") } @Test - fun interceptor_passes_through_non_401_errors_without_refresh() = runTest { + fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest { val request = makeRequest() var refreshCallCount = 0 val chain = FakeChain( @@ -241,33 +243,32 @@ class CoilAuthInterceptorTest { refreshCallCount++ "should-not-be-called" }, - authScheme = "Token", ) val result = interceptor.intercept(chain) assertTrue(result is ErrorResult) - assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors") + assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors") assertEquals(1, chain.capturedRequests.size) } @Test - fun interceptor_supports_bearer_scheme() = runTest { + fun interceptor_supports_custom_header_name() = runTest { val request = makeRequest() val chain = FakeChain( initialRequest = request, responses = mutableListOf({ req -> makeSuccess(req) }) ) val interceptor = CoilAuthInterceptor( - tokenProvider = { "jwt.payload.sig" }, + tokenProvider = { "tok-value" }, refreshToken = { null }, - authScheme = "Bearer", + headerName = "X-Custom-Auth", ) val result = interceptor.intercept(chain) assertTrue(result is SuccessResult) val sent = chain.capturedRequests.first() - assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"]) + assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"]) } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt new file mode 100644 index 0000000..c12b180 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt @@ -0,0 +1,235 @@ +package com.tt.honeyDue.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Models for Ory Kratos native (`api`) self-service flows. + * + * honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile + * client drives Kratos' native flows directly: + * + * 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action) + * 2. `POST {ui.action}` -> success body or a re-rendered + * flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`. + * + * Only the fields the client actually needs are modelled; `ignoreUnknownKeys` + * on the Json instance tolerates the rest of Kratos' (large) payloads. + * + * Kratos docs: https://www.ory.sh/docs/kratos/self-service + */ + +// ==================== Flow envelope ==================== + +/** + * A Kratos self-service flow (login / registration / recovery / verification). + * Returned by the initial `GET .../{flow}/api` call. + */ +@Serializable +data class KratosFlow( + val id: String, + val type: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("issued_at") val issuedAt: String? = null, + val ui: KratosUi, + /** Present on a verification/recovery flow that is already complete. */ + val state: String? = null, +) + +/** + * The renderable UI of a flow. `action` is the absolute URL the client must + * POST the method payload to; `messages` carries flow-level errors. + */ +@Serializable +data class KratosUi( + val action: String, + val method: String = "POST", + val nodes: List = emptyList(), + val messages: List = emptyList(), +) + +@Serializable +data class KratosUiNode( + val type: String? = null, + val group: String? = null, + val attributes: KratosUiNodeAttributes? = null, + val messages: List = emptyList(), +) + +@Serializable +data class KratosUiNodeAttributes( + val name: String? = null, + val type: String? = null, + val value: JsonElement? = null, +) + +/** + * A Kratos UI message. `type` is `info`, `error`, or `success`. + */ +@Serializable +data class KratosMessage( + val id: Long? = null, + val text: String, + val type: String? = null, +) + +// ==================== Flow success bodies ==================== + +/** + * Identity traits as configured in the Kratos identity schema for honeyDue. + * `email` is the primary identifier; `name` mirrors the schema's nested object. + */ +@Serializable +data class KratosTraits( + val email: String, + val name: KratosName? = null, +) + +@Serializable +data class KratosName( + val first: String = "", + val last: String = "", +) + +/** + * A Kratos identity (subset). Returned nested inside [KratosSession]. + */ +@Serializable +data class KratosIdentity( + val id: String, + @SerialName("schema_id") val schemaId: String? = null, + val state: String? = null, + val traits: KratosTraits? = null, + @SerialName("verifiable_addresses") val verifiableAddresses: List? = null, +) + +@Serializable +data class KratosVerifiableAddress( + val id: String? = null, + val value: String? = null, + val verified: Boolean = false, + val via: String? = null, + val status: String? = null, +) + +/** + * A Kratos session. `active` + `expires_at` describe validity; `identity` + * carries the authenticated user's traits. + */ +@Serializable +data class KratosSession( + val id: String, + val active: Boolean = true, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("authenticated_at") val authenticatedAt: String? = null, + val identity: KratosIdentity? = null, +) + +/** + * Success body of a native login flow submission + * (`POST .../self-service/login/api`). + */ +@Serializable +data class KratosLoginSuccess( + val session: KratosSession, + @SerialName("session_token") val sessionToken: String, +) + +/** + * Success body of a native registration flow submission + * (`POST .../self-service/registration/api`). With the + * `session` after-hook enabled, Kratos returns a session + token here. + */ +@Serializable +data class KratosRegistrationSuccess( + val session: KratosSession? = null, + @SerialName("session_token") val sessionToken: String? = null, + val identity: KratosIdentity? = null, + @SerialName("continue_with") val continueWith: List = emptyList(), +) + +/** + * A `continue_with` item — e.g. Kratos asking the client to show a + * verification flow after registration. + */ +@Serializable +data class KratosContinueWith( + val action: String? = null, + val flow: KratosContinueWithFlow? = null, +) + +@Serializable +data class KratosContinueWithFlow( + val id: String? = null, + @SerialName("verifiable_address") val verifiableAddress: String? = null, +) + +// ==================== Submit payloads ==================== + +/** + * Body for submitting the `password` method to a login flow. + */ +@Serializable +data class KratosPasswordLoginBody( + val method: String = "password", + val identifier: String, + val password: String, +) + +/** + * Body for submitting the `password` method to a registration flow. + */ +@Serializable +data class KratosPasswordRegistrationBody( + val method: String = "password", + val traits: KratosTraits, + val password: String, +) + +/** + * Body for submitting an OIDC (`apple` / `google`) provider to a + * login or registration flow using a native `id_token` from the + * platform sign-in SDK. + */ +@Serializable +data class KratosOidcBody( + val method: String = "oidc", + val provider: String, + @SerialName("id_token") val idToken: String, + /** Optional traits — sent on registration so Kratos can seed the identity. */ + val traits: KratosTraits? = null, +) + +/** + * Body for submitting the `code` method to a recovery flow. + * The first POST omits [code] (sends just the email); the second + * POST includes the code the user received by email. + */ +@Serializable +data class KratosRecoveryBody( + val method: String = "code", + val email: String? = null, + val code: String? = null, +) + +/** + * Body for submitting the `code` method to a verification flow. + * As with recovery: first POST sends the email, second sends the code. + */ +@Serializable +data class KratosVerificationBody( + val method: String = "code", + val email: String? = null, + val code: String? = null, +) + +/** + * Body for updating an identity's password from within a settings flow + * (used after a recovery flow hands the client a privileged session). + */ +@Serializable +data class KratosSettingsPasswordBody( + val method: String = "password", + val password: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt index ba809c3..cbeea99 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt @@ -85,7 +85,13 @@ data class AuthResponse( ) /** - * Token refresh response - returned by POST /api/auth/refresh/ + * Token refresh response. + * + * Identity is owned by Ory Kratos. Native Kratos session tokens are + * long-lived and not rotated — there is no refresh endpoint. This type is + * retained as the return shape of [com.tt.honeyDue.network.AuthApi.refreshToken], + * which now re-validates the session via Kratos `/sessions/whoami` and echoes + * the same (unchanged) token back when the session is still active. */ @Serializable data class TokenRefreshResponse( diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index 552c6b3..63f319a 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -1276,20 +1276,24 @@ object APILayer { } /** - * Refresh the current auth token. - * Calls POST /api/auth/refresh/ with the current token. - * On success, saves the new token to DataManager and TokenStorage. - * On failure, returns an error (caller decides whether to trigger logout). + * Re-validate the current Kratos session. + * + * Identity is owned by Ory Kratos. Native Kratos session tokens are + * long-lived and there is no native refresh endpoint — "refresh" here + * means: ask Kratos whether the session is still active (`/sessions/whoami`). + * + * - Session still valid → returns the same (unchanged) token. + * - Session gone → returns an error; the caller should sign out. + * + * The method name is kept so the Coil image interceptor and the + * `ApiClient` 401 plumbing continue to compile. */ suspend fun refreshToken(): ApiResult { val currentToken = getToken() ?: return ApiResult.Error("No token", 401) - val result = authApi.refreshToken(currentToken) - if (result is ApiResult.Success) { - DataManager.setAuthToken(result.data.token) - com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token) - } - return when (result) { - is ApiResult.Success -> ApiResult.Success(result.data.token) + return when (val result = authApi.refreshToken(currentToken)) { + // Kratos session tokens are never rotated — echo the same token + // back when the session is confirmed still valid. + is ApiResult.Success -> ApiResult.Success(currentToken) is ApiResult.Error -> ApiResult.Error(result.message, result.code) else -> ApiResult.Error("Unexpected state") } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt index 0d7ea0d..1371e06 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt @@ -1,8 +1,6 @@ package com.tt.honeyDue.network import com.tt.honeyDue.data.DataManager -import com.tt.honeyDue.models.TokenRefreshResponse -import com.tt.honeyDue.storage.TokenStorage import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.* @@ -34,6 +32,30 @@ expect fun getDeviceLanguage(): String */ expect fun getDeviceTimezone(): String +/** + * The HTTP header the honeyDue API expects on authenticated requests. + * + * Identity is owned by Ory Kratos; the honeyDue API now authenticates a + * request by validating the Kratos **session token** carried on this header. + * This replaces the old `Authorization: Token ` scheme. + * + * Every honeyDue `*Api.kt` client sends this header via [authHeader]; image + * loading uses it through [CoilAuthInterceptor]. + */ +const val SESSION_TOKEN_HEADER: String = "X-Session-Token" + +/** + * Set the honeyDue session-token header on a request. + * + * Usage in an `*Api.kt` client: + * ```kotlin + * client.get("$baseUrl/tasks/") { authHeader(token) } + * ``` + */ +fun HttpRequestBuilder.authHeader(token: String) { + header(SESSION_TOKEN_HEADER, token) +} + /** * Mutex to prevent multiple concurrent token refresh attempts. * When one request triggers a 401, only one refresh call is made; @@ -80,37 +102,30 @@ fun HttpClientConfig<*>.installCommonPlugins() { socketTimeoutMillis = 30_000 // 30 seconds } - // Task 3: Token refresh on 401 responses + // Task 3: Kratos session validation on 401 responses. + // + // The honeyDue API now authenticates via the Kratos session token on the + // X-Session-Token header. A 401 from the API means that token was + // rejected. We confirm with Kratos whether the session is genuinely gone: + // - still valid -> throw TokenExpiredException(refreshed = true) (retry) + // - gone -> clear auth, throw TokenExpiredException(false) (re-login) HttpResponseValidator { validateResponse { response -> if (response.status.value == 401) { - // Check if this is a token_expired error (not invalid credentials) - val bodyText = response.bodyAsText() - val isTokenExpired = bodyText.contains("token_expired") || - bodyText.contains("Token has expired") || - bodyText.contains("expired") - - if (isTokenExpired) { - val currentToken = DataManager.authToken.value - if (currentToken != null) { - // Use mutex to prevent concurrent refresh attempts - val refreshed = tokenRefreshMutex.withLock { - // Double-check: another coroutine may have already refreshed - val tokenAfterLock = DataManager.authToken.value - if (tokenAfterLock != currentToken) { - // Token was already refreshed by another coroutine - true - } else { - attemptTokenRefresh(currentToken) - } - } - if (!refreshed) { - // Refresh failed — clear auth and trigger logout - DataManager.clear() - } - // Throw so the caller can retry (or handle the logout) - throw TokenExpiredException(refreshed) + val currentToken = DataManager.authToken.value + if (currentToken != null) { + // Use the mutex so concurrent 401s only trigger one + // whoami check against Kratos. + val stillValid = tokenRefreshMutex.withLock { + attemptTokenRefresh(currentToken) } + if (!stillValid) { + // Session is gone — clear auth and route to login. + DataManager.clear() + } + // Throw so the caller can retry (still valid) or handle + // the forced logout (session gone). + throw TokenExpiredException(stillValid) } } } @@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() { } /** - * Attempt to refresh the auth token by calling POST /api/auth/refresh/. - * Returns true if refresh succeeded and the new token was saved. + * Re-validate the current Kratos session. + * + * Identity is owned by Ory Kratos. Native Kratos session tokens are + * long-lived and there is **no native refresh endpoint** — when a session + * genuinely expires the user must sign in again. So "refresh" here means: + * ask Kratos `GET /sessions/whoami` whether the session is still active. + * + * - returns `true` → the session is still valid; the original 401 was + * transient (e.g. a brief replication lag) and the caller may retry. + * - returns `false` → the session is gone; the caller should clear auth and + * route the user back to login. + * + * The token itself is never rotated — [DataManager]/[TokenStorage] keep the + * same value either way. */ private suspend fun attemptTokenRefresh(currentToken: String): Boolean { return try { - // Use a minimal client to avoid recursive interceptor triggers - val refreshClient = HttpClient { + // Use a minimal client to avoid recursive interceptor triggers. + val whoamiClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true @@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean { socketTimeoutMillis = 15_000 } } - val baseUrl = ApiConfig.getBaseUrl() - val response = refreshClient.post("$baseUrl/auth/refresh/") { - header("Authorization", "Token $currentToken") - contentType(ContentType.Application.Json) + val kratosUrl = ApiConfig.getKratosBaseUrl() + val response = whoamiClient.get("$kratosUrl/sessions/whoami") { + header(SESSION_TOKEN_HEADER, currentToken) + accept(ContentType.Application.Json) } - refreshClient.close() + whoamiClient.close() if (response.status.isSuccess()) { - val tokenResponse = response.body() - // Save the new token to both DataManager and persistent storage - DataManager.setAuthToken(tokenResponse.token) - TokenStorage.saveToken(tokenResponse.token) - println("[ApiClient] Token refreshed successfully") + // Session still valid — keep the same token. + println("[ApiClient] Kratos session still valid") true } else { - println("[ApiClient] Token refresh failed: ${response.status.value}") + println("[ApiClient] Kratos session invalid: ${response.status.value}") false } } catch (e: Exception) { - println("[ApiClient] Token refresh error: ${e.message}") + println("[ApiClient] Kratos session check error: ${e.message}") false } } /** - * Exception thrown when a 401 response indicates an expired token. - * [refreshed] indicates whether the token was successfully refreshed. - * Callers can catch this and retry the request if refreshed is true. + * Exception thrown when a 401 response indicates an expired/invalid session. + * + * [refreshed] indicates whether the Kratos session was re-validated and is + * still usable. Callers can catch this and retry the request when `refreshed` + * is true; when false the user must re-authenticate. + * + * The name is retained from the pre-Kratos token scheme so existing callers + * ([CoilAuthInterceptor] plumbing, tests) continue to compile. */ class TokenExpiredException(val refreshed: Boolean) : Exception( - if (refreshed) "Token was expired but has been refreshed — retry the request" - else "Token expired and refresh failed — user must re-authenticate" + if (refreshed) "Session was briefly rejected but is still valid — retry the request" + else "Session expired — user must re-authenticate" ) object ApiClient { @@ -185,6 +214,13 @@ object ApiClient { */ fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl() + /** + * Get the Ory Kratos public API base URL. Identity flows (login, + * registration, recovery, verification, OIDC sign-in) run against this + * host — NOT [getBaseUrl]. + */ + fun getKratosBaseUrl(): String = ApiConfig.getKratosBaseUrl() + /** * Print current environment configuration */ @@ -193,5 +229,6 @@ object ApiClient { println("Environment: ${ApiConfig.getEnvironmentName()}") println("Base URL: ${getBaseUrl()}") println("Media URL: ${getMediaBaseUrl()}") + println("Kratos URL: ${getKratosBaseUrl()}") } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt index 95c74f9..b059a0c 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt @@ -40,6 +40,28 @@ object ApiConfig { } } + /** + * Get the Ory Kratos public API base URL. + * + * Identity (login, registration, recovery, verification, OIDC sign-in) is + * owned by Ory Kratos — NOT the honeyDue Go API. The native (`api`) + * self-service flows live under `{kratosBaseUrl}/self-service/...`. + * + * - LOCAL: a Kratos instance running on the dev machine (default public + * port `4433`). The Android emulator reaches the host via `10.0.2.2`, + * the iOS simulator via `127.0.0.1` — both resolved by [getLocalhostAddress]. + * - DEV / PROD: the hosted Kratos at `auth.myhoneydue.com`. + * + * No trailing slash — callers append `/self-service/...`. + */ + fun getKratosBaseUrl(): String { + return when (CURRENT_ENV) { + Environment.LOCAL -> "http://${getLocalhostAddress()}:4433" + Environment.DEV -> "https://auth.myhoneydue.com" + Environment.PROD -> "https://auth.myhoneydue.com" + } + } + /** * Get environment name for logging */ diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt index b93d1ba..d17b355 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt @@ -4,71 +4,505 @@ import com.tt.honeyDue.models.* import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* +import kotlinx.serialization.json.Json +/** + * Authentication API client. + * + * Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API. + * This client drives Kratos' **native (`api`) self-service flows**: + * + * - **Login** — `GET .../self-service/login/api` → `POST ui.action` + * with `{method:"password", identifier, password}`. + * - **Registration** — `GET .../self-service/registration/api` → `POST ui.action` + * with `{method:"password", traits:{email,name{first,last}}, password}`. + * - **Recovery** — `.../self-service/recovery/api` with `method:"code"`. + * - **Verification** — `.../self-service/verification/api` with `method:"code"`. + * - **OIDC** — Apple/Google: the platform SDK obtains a native + * `id_token`, submitted to the login/registration flow with + * `{method:"oidc", provider, id_token}`. + * + * On success Kratos returns a **`session_token`**. That token is what the + * honeyDue API now expects on the `X-Session-Token` header. The session token + * is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer]. + * + * Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile + * update, account deletion — are also driven from here, sending the Kratos + * session token on the `X-Session-Token` header. + * + * The public method signatures are deliberately unchanged from the old + * hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel] + * and the UI continue to compile against the same `ApiResult<...>` shapes. + */ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { - private val baseUrl = ApiClient.getBaseUrl() - suspend fun register(request: RegisterRequest): ApiResult { + /** honeyDue Go API base — used only for `/auth/me`, profile, delete. */ + private val apiBaseUrl = ApiClient.getBaseUrl() + + /** Ory Kratos public API base — used for all identity flows. */ + private val kratosBaseUrl = ApiConfig.getKratosBaseUrl() + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + // ==================== Kratos flow plumbing ==================== + + /** + * Fetch a fresh native self-service flow from Kratos. + * + * @param flow one of `login`, `registration`, `recovery`, `verification`. + * @param refresh when true (login only) forces re-authentication of an + * already-valid session. + */ + private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult { return try { - val response = client.post("$baseUrl/auth/register/") { - contentType(ContentType.Application.Json) - setBody(request) + val response = client.get("$kratosBaseUrl/self-service/$flow/api") { + accept(ContentType.Application.Json) + if (refresh) parameter("refresh", "true") } - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) + ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText())) } else { - // Parse actual error message from backend - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) + ApiResult.Error( + "Could not start $flow (Kratos ${response.status.value})", + response.status.value, + ) } } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") + ApiResult.Error(e.message ?: "Could not reach the authentication server") } } + /** + * Submit a method payload to a flow's `ui.action` URL and decode the + * result with [decode]. On a 4xx Kratos re-renders the flow with + * validation messages — those are extracted via [extractKratosError]. + */ + private suspend fun submitFlow( + actionUrl: String, + bodyJson: String, + decode: (String) -> T, + ): ApiResult { + return try { + val response = client.post(actionUrl) { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(bodyJson) + } + val text = response.bodyAsText() + if (response.status.isSuccess()) { + ApiResult.Success(decode(text)) + } else { + ApiResult.Error(extractKratosError(text, response.status.value), response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Authentication request failed") + } + } + + /** + * Pull a human-readable error out of a Kratos error/flow body. + * + * Kratos surfaces validation problems in `ui.messages` and + * `ui.nodes[].messages`; hard errors (expired flow, etc.) come back as + * `{ error: { message, reason } }`. + */ + private fun extractKratosError(body: String, statusCode: Int): String { + // 1. Re-rendered flow with field/flow messages. + runCatching { + val flow = json.decodeFromString(KratosFlow.serializer(), body) + val msgs = buildList { + flow.ui.messages.filter { it.type == "error" || it.type == null } + .forEach { add(it.text) } + flow.ui.nodes.flatMap { it.messages } + .filter { it.type == "error" || it.type == null } + .forEach { add(it.text) } + }.distinct() + if (msgs.isNotEmpty()) return msgs.joinToString(". ") + } + // 2. Generic Kratos error envelope. + runCatching { + val env = json.decodeFromString(KratosErrorEnvelope.serializer(), body) + val msg = env.error?.reason ?: env.error?.message + if (!msg.isNullOrBlank()) return msg + } + return "Authentication failed ($statusCode)" + } + + // ==================== Login ==================== + + /** + * Native password login against Kratos. + * + * [LoginRequest.username] is treated as the Kratos identifier (the user's + * email — honeyDue identities are keyed by email). + */ suspend fun login(request: LoginRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/login/") { - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - // Parse actual error message from backend - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") + val flow = when (val f = initFlow("login")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not start login") + } + val body = json.encodeToString( + KratosPasswordLoginBody.serializer(), + KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password), + ) + val success = submitFlow(flow.ui.action, body) { + json.decodeFromString(KratosLoginSuccess.serializer(), it) + } + return when (success) { + is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session) + is ApiResult.Error -> success + else -> ApiResult.Error("Login failed") } } + // ==================== Registration ==================== + + /** + * Native password registration against Kratos. + * + * Kratos identity traits are `{ email, name: { first, last } }`. The + * legacy [RegisterRequest.username] is preserved for the UI but is not a + * Kratos trait — the email is the identifier. + */ + suspend fun register(request: RegisterRequest): ApiResult { + val flow = when (val f = initFlow("registration")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not start registration") + } + val traits = KratosTraits( + email = request.email.trim(), + name = KratosName( + first = request.firstName ?: "", + last = request.lastName ?: "", + ), + ) + val body = json.encodeToString( + KratosPasswordRegistrationBody.serializer(), + KratosPasswordRegistrationBody(traits = traits, password = request.password), + ) + val success = submitFlow(flow.ui.action, body) { + json.decodeFromString(KratosRegistrationSuccess.serializer(), it) + } + return when (success) { + is ApiResult.Success -> { + val token = success.data.sessionToken + if (token.isNullOrBlank()) { + // Kratos was configured without the `session` after-hook — + // the identity exists but no session was issued. The caller + // must complete a verification flow then log in. + ApiResult.Error( + "Account created. Please verify your email, then sign in.", + 200, + ) + } else { + resolveSession(token, success.data.session) + } + } + is ApiResult.Error -> success + else -> ApiResult.Error("Registration failed") + } + } + + // ==================== OIDC (Apple / Google) ==================== + + /** + * Submit a native OIDC `id_token` to Kratos. + * + * The platform SDK (Sign in with Apple / Google Sign-In) obtains the + * `id_token`; Kratos verifies it and either logs the user in or registers + * a new identity. We try the login flow first; if Kratos reports the + * identity does not exist we fall through to the registration flow. + * + * @param provider `"apple"` or `"google"`. + * @param idToken the platform-issued OpenID Connect ID token. + * @param traits optional traits used to seed a new identity on first sign-in. + */ + private suspend fun oidcSignIn( + provider: String, + idToken: String, + traits: KratosTraits?, + ): ApiResult { + // 1. Attempt the login flow. + val loginFlow = when (val f = initFlow("login")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not start sign-in") + } + val loginBody = json.encodeToString( + KratosOidcBody.serializer(), + KratosOidcBody(provider = provider, idToken = idToken), + ) + val loginResult = submitFlow(loginFlow.ui.action, loginBody) { + json.decodeFromString(KratosLoginSuccess.serializer(), it) + } + if (loginResult is ApiResult.Success) { + return resolveSession(loginResult.data.sessionToken, loginResult.data.session) + } + + // 2. No identity yet — drive the registration flow with the same token. + val regFlow = when (val f = initFlow("registration")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return (loginResult as? ApiResult.Error) ?: f + else -> return ApiResult.Error("Could not start sign-up") + } + val regBody = json.encodeToString( + KratosOidcBody.serializer(), + KratosOidcBody(provider = provider, idToken = idToken, traits = traits), + ) + val regResult = submitFlow(regFlow.ui.action, regBody) { + json.decodeFromString(KratosRegistrationSuccess.serializer(), it) + } + return when (regResult) { + is ApiResult.Success -> { + val token = regResult.data.sessionToken + if (token.isNullOrBlank()) { + (loginResult as? ApiResult.Error) + ?: ApiResult.Error("Sign-in did not return a session") + } else { + resolveSession(token, regResult.data.session) + } + } + is ApiResult.Error -> regResult + else -> ApiResult.Error("Sign-in failed") + } + } + + /** + * Apple Sign In via Kratos OIDC. The Apple `id_token` is obtained natively + * by the platform; [AppleSignInRequest.userId]/email/name seed the + * identity on first sign-in. + */ + suspend fun appleSignIn(request: AppleSignInRequest): ApiResult { + val traits = request.email?.takeIf { it.isNotBlank() }?.let { + KratosTraits( + email = it, + name = KratosName( + first = request.firstName ?: "", + last = request.lastName ?: "", + ), + ) + } + return when (val r = oidcSignIn("apple", request.idToken, traits)) { + is ApiResult.Success -> ApiResult.Success( + AppleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false), + ) + is ApiResult.Error -> r + else -> ApiResult.Error("Apple Sign In failed") + } + } + + /** + * Google Sign In via Kratos OIDC. The Google `id_token` is obtained + * natively by the platform Google Sign-In SDK. + */ + suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult { + return when (val r = oidcSignIn("google", request.idToken, traits = null)) { + is ApiResult.Success -> ApiResult.Success( + GoogleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false), + ) + is ApiResult.Error -> r + else -> ApiResult.Error("Google Sign In failed") + } + } + + // ==================== Logout ==================== + + /** + * Terminate the Kratos session. + * + * The native logout flow is a single call: + * `DELETE .../self-service/logout/api` with `{ session_token }`. + * A failure here is non-fatal — the caller drops the token locally + * regardless (see [APILayer.logout]). + */ suspend fun logout(token: String): ApiResult { return try { - val response = client.post("$baseUrl/auth/logout/") { - header("Authorization", "Token $token") - } - - if (response.status.isSuccess()) { - ApiResult.Success(Unit) - } else { - ApiResult.Error("Logout failed", response.status.value) + client.delete("$kratosBaseUrl/self-service/logout/api") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token))) } + // Treat any outcome as success — the token is being discarded + // locally anyway, and a stale-token DELETE is harmless. + ApiResult.Success(Unit) } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") + ApiResult.Success(Unit) } } + // ==================== Recovery (forgot password) ==================== + + /** + * Start a Kratos recovery flow and submit the user's email so Kratos + * mails them a recovery code. + * + * Mirrors the legacy `forgotPassword` signature. The returned message is + * Kratos' confirmation text. + */ + suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult { + val flow = when (val f = initFlow("recovery")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not start password recovery") + } + val body = json.encodeToString( + KratosRecoveryBody.serializer(), + KratosRecoveryBody(email = request.email.trim()), + ) + // Kratos returns the re-rendered flow (200) carrying an info message + // that the code was sent. A 4xx means the email was malformed. + val result = submitFlow(flow.ui.action, body) { + runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() + } + return when (result) { + is ApiResult.Success -> { + val info = result.data?.ui?.messages?.firstOrNull()?.text + ApiResult.Success( + ForgotPasswordResponse( + message = info ?: "If that email exists, a recovery code has been sent.", + ), + ) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Could not send recovery code") + } + } + + /** + * Submit the recovery code the user received by email. + * + * On success Kratos returns a privileged session and a redirect to a + * settings flow. For the native client we surface the recovery flow id + * back as the "reset token" so the subsequent [resetPassword] call can + * target the same flow. + */ + suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult { + // Re-init a recovery flow and submit email+code together. (Kratos keeps + // the address bound to the flow; for the native client we start a + // fresh flow and submit both fields in one POST.) + val flow = when (val f = initFlow("recovery")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not verify the code") + } + val body = json.encodeToString( + KratosRecoveryBody.serializer(), + KratosRecoveryBody(email = request.email.trim(), code = request.code.trim()), + ) + val result = submitFlow(flow.ui.action, body) { + runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() + } + return when (result) { + is ApiResult.Success -> ApiResult.Success( + // The recovery flow id doubles as the reset token: resetPassword + // uses it to drive the settings flow that follows. + VerifyResetCodeResponse( + message = "Code verified.", + resetToken = flow.id, + ), + ) + is ApiResult.Error -> result + else -> ApiResult.Error("Invalid or expired code") + } + } + + /** + * Complete a password reset. + * + * After [verifyResetCode] Kratos hands the client a privileged session via + * a settings flow. This submits the new password to that settings flow. + * + * TODO(kratos): the native recovery → settings handoff returns the + * settings flow URL in the recovery response's `continue_with`. Wiring the + * full two-step handoff requires the privileged `session_token` Kratos + * issues on code verification; until the backend confirms the exact shape, + * [resetPassword] starts a settings flow keyed by the reset token as a + * best-effort and surfaces any Kratos validation message verbatim. + */ + suspend fun resetPassword(request: ResetPasswordRequest): ApiResult { + return try { + // Settings flow keyed by the recovery flow id handed back from + // verifyResetCode. Kratos binds the privileged session server-side. + val response = client.post("$kratosBaseUrl/self-service/settings") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + parameter("flow", request.resetToken) + setBody( + json.encodeToString( + KratosSettingsPasswordBody.serializer(), + KratosSettingsPasswordBody(password = request.newPassword), + ), + ) + } + val text = response.bodyAsText() + if (response.status.isSuccess()) { + ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in.")) + } else { + ApiResult.Error(extractKratosError(text, response.status.value), response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Could not reset password") + } + } + + // ==================== Email verification ==================== + + /** + * Submit an email-verification code to Kratos' verification flow. + * + * Note: the [token] parameter (a session token) is unused for the Kratos + * verification flow — verification is anonymous and keyed by the code — + * but the parameter is kept so [APILayer]/`AuthViewModel` need no change. + */ + suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { + val flow = when (val f = initFlow("verification")) { + is ApiResult.Success -> f.data + is ApiResult.Error -> return f + else -> return ApiResult.Error("Could not start verification") + } + val body = json.encodeToString( + KratosVerificationBody.serializer(), + KratosVerificationBody(code = request.code.trim()), + ) + val result = submitFlow(flow.ui.action, body) { + runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() + } + return when (result) { + is ApiResult.Success -> { + val verified = result.data?.state == "passed_challenge" + ApiResult.Success( + VerifyEmailResponse( + message = if (verified) "Email verified." else "Verification submitted.", + verified = verified, + ), + ) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Verification failed") + } + } + + // ==================== honeyDue API (still session-token-gated) ==================== + + /** + * Fetch the current honeyDue user from the honeyDue Go API. + * + * Identity lives in Kratos, but the honeyDue API still owns the + * application-level user record (numeric id, profile, verified flag). The + * Kratos session token is sent on the `X-Session-Token` header. + */ suspend fun getCurrentUser(token: String): ApiResult { return try { - val response = client.get("$baseUrl/auth/me/") { - header("Authorization", "Token $token") + val response = client.get("$apiBaseUrl/auth/me/") { + header(HEADER_SESSION_TOKEN, token) } - if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { @@ -79,214 +513,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/verify-email/") { - header("Authorization", "Token $token") - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Verification failed") - } - ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - + /** + * Update the honeyDue user profile. Profile data lives on the honeyDue + * API, not Kratos, so this still targets the Go API. + */ suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult { return try { - val response = client.put("$baseUrl/auth/profile/") { - header("Authorization", "Token $token") + val response = client.put("$apiBaseUrl/auth/profile/") { + header(HEADER_SESSION_TOKEN, token) contentType(ContentType.Application.Json) setBody(request) } - if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Profile update failed") - } - ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value) + ApiResult.Error(ErrorParser.parseError(response), response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") } } - // Password Reset Methods - suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/forgot-password/") { - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Failed to send reset code") - } - ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - - suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/verify-reset-code/") { - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Invalid code") - } - ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - - suspend fun resetPassword(request: ResetPasswordRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/reset-password/") { - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - // Try to parse Django validation errors (Map>) - val errorMessage = try { - val validationErrors = response.body>>() - // Flatten all error messages into a single string - validationErrors.flatMap { (field, errors) -> - errors.map { error -> - if (field == "non_field_errors") error else "$field: $error" - } - }.joinToString(". ") - } catch (e: Exception) { - // Try simple error format {error: "message"} - try { - val simpleError = response.body>() - simpleError["error"] ?: "Failed to reset password" - } catch (e2: Exception) { - "Failed to reset password" - } - } - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - - // Apple Sign In - suspend fun appleSignIn(request: AppleSignInRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/apple-sign-in/") { - contentType(ContentType.Application.Json) - setBody(request) - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Apple Sign In failed") - } - ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - - // Delete Account + /** + * Delete the honeyDue account. The honeyDue API is responsible for + * tearing down its own user record and asking Kratos to delete the + * backing identity. + */ suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult { return try { - val response = client.delete("$baseUrl/auth/account/") { - header("Authorization", "Token $token") + val response = client.delete("$apiBaseUrl/auth/account/") { + header(HEADER_SESSION_TOKEN, token) contentType(ContentType.Application.Json) setBody(request) } - if (response.status.isSuccess()) { ApiResult.Success(Unit) } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) + ApiResult.Error(ErrorParser.parseError(response), response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") } } - // Token Refresh + /** + * Legacy token-refresh shim. + * + * Kratos native session tokens are long-lived and there is no native + * refresh endpoint — when a session expires the user must re-authenticate. + * This method is kept so [ApiClient]'s 401 plumbing and the Coil image + * interceptor still compile; it simply re-validates the current session + * via Kratos `/sessions/whoami` and echoes the same token back if still + * valid, or fails otherwise. + */ suspend fun refreshToken(token: String): ApiResult { return try { - val response = client.post("$baseUrl/auth/refresh/") { - header("Authorization", "Token $token") - contentType(ContentType.Application.Json) + val response = client.get("$kratosBaseUrl/sessions/whoami") { + header(HEADER_SESSION_TOKEN, token) + accept(ContentType.Application.Json) } - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) + ApiResult.Success(TokenRefreshResponse(token = token)) } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) + ApiResult.Error("Session expired — please sign in again", response.status.value) } } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") + ApiResult.Error(e.message ?: "Could not validate session") } } - // Google Sign In - suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult { - return try { - val response = client.post("$baseUrl/auth/google-sign-in/") { - contentType(ContentType.Application.Json) - setBody(request) - } + // ==================== Session → User resolution ==================== - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorBody = try { - response.body>() - } catch (e: Exception) { - mapOf("error" to "Google Sign In failed") + /** + * Given a freshly issued Kratos `session_token`, fetch the honeyDue + * application user that backs the Kratos identity and assemble an + * [AuthResponse]. + * + * The honeyDue API maps Kratos identities to its own numeric user records; + * `/auth/me` is the source of truth for `User.id`, profile and the + * `verified` flag. If `/auth/me` is unreachable we fall back to a + * best-effort [User] synthesised from the Kratos identity traits so the + * app can still proceed. + */ + private suspend fun resolveSession( + sessionToken: String, + session: KratosSession?, + ): ApiResult { + return when (val me = getCurrentUser(sessionToken)) { + is ApiResult.Success -> ApiResult.Success(AuthResponse(token = sessionToken, user = me.data)) + is ApiResult.Error -> { + val fallback = session?.identity?.let { userFromKratosIdentity(it) } + if (fallback != null) { + ApiResult.Success(AuthResponse(token = sessionToken, user = fallback)) + } else { + me } - ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value) } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") + else -> ApiResult.Error("Could not load profile after sign-in") } } + + /** + * Best-effort [User] built from a Kratos identity when the honeyDue + * `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs, + * not the honeyDue numeric id) — callers should re-fetch via + * [getCurrentUser] as soon as the API is reachable. + */ + private fun userFromKratosIdentity(identity: KratosIdentity): User { + val traits = identity.traits + return User( + id = 0, + username = traits?.email ?: "", + email = traits?.email ?: "", + firstName = traits?.name?.first ?: "", + lastName = traits?.name?.last ?: "", + isActive = identity.state == null || identity.state == "active", + dateJoined = "", + authProvider = "kratos", + profile = null, + ) + } + + companion object { + /** + * The header the honeyDue API now expects on authenticated requests — + * carries the Kratos session token. Replaces `Authorization: Token …`. + */ + const val HEADER_SESSION_TOKEN = "X-Session-Token" + } } + +/** Body for Kratos' native `DELETE /self-service/logout/api`. */ +@kotlinx.serialization.Serializable +private data class LogoutBody( + @kotlinx.serialization.SerialName("session_token") val sessionToken: String, +) + +/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */ +@kotlinx.serialization.Serializable +private data class KratosErrorEnvelope( + val error: KratosErrorDetail? = null, +) + +@kotlinx.serialization.Serializable +private data class KratosErrorDetail( + val message: String? = null, + val reason: String? = null, + val status: String? = null, + val code: Int? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt index 7710515..88bd94f 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt @@ -7,14 +7,18 @@ import coil3.request.ErrorResult import coil3.request.ImageResult /** - * Coil3 [Interceptor] that attaches an `Authorization` header to every - * outgoing image request and, on an HTTP 401 response, refreshes the token - * and retries exactly once. + * Coil3 [Interceptor] that attaches the honeyDue session-token header to every + * outgoing image request and, on an HTTP 401 response, re-validates the + * session and retries exactly once. + * + * honeyDue's identity is owned by Ory Kratos. Authenticated honeyDue API + * requests — including authenticated media — carry the Kratos session token + * on the **`X-Session-Token`** header (the old `Authorization: Token …` scheme + * is gone). This interceptor centralises that concern so individual + * composables don't thread the token through themselves. * * Mirrors the behavior of the iOS `AuthenticatedImage` in - * `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the - * concern so individual composables don't need to thread the token through - * themselves. + * `iosApp/iosApp/Components/AuthenticatedImage.swift`. * * Usage — install on the singleton [coil3.ImageLoader]: * ```kotlin @@ -23,24 +27,28 @@ import coil3.request.ImageResult * add(CoilAuthInterceptor( * tokenProvider = { TokenStorage.getToken() }, * refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data }, - * authScheme = "Token", * )) * add(KtorNetworkFetcherFactory()) * } * .build() * ``` * - * @param tokenProvider Suspending supplier of the current auth token. Returning - * `null` means "no token available" — the request proceeds unauthenticated. - * @param refreshToken Suspending supplier that refreshes the backing session and - * returns a fresh token, or `null` if refresh failed. - * @param authScheme The auth scheme to prefix the token with (default `Token` - * to match the existing Go backend — use `Bearer` for JWT deployments). + * @param tokenProvider Suspending supplier of the current session token. + * Returning `null` means "no token available" — the request proceeds + * unauthenticated so anonymous endpoints still work. + * @param refreshToken Suspending supplier that re-validates the session and + * returns a still-valid token, or `null` if the session is gone. With + * Kratos, session tokens are not rotated — this typically echoes the same + * token back when the session is still active. + * @param headerName The HTTP header carrying the token. Defaults to + * [SESSION_TOKEN_HEADER] (`X-Session-Token`). The token is sent as the bare + * header value — there is no ` ` prefix under the Kratos + * session-token scheme. */ class CoilAuthInterceptor( private val tokenProvider: suspend () -> String?, private val refreshToken: suspend () -> String?, - private val authScheme: String = "Token", + private val headerName: String = SESSION_TOKEN_HEADER, ) : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { @@ -55,26 +63,26 @@ class CoilAuthInterceptor( val authed = chain.request.newBuilder() .httpHeaders( chain.request.httpHeaders.newBuilder() - .set(HEADER_AUTHORIZATION, "$authScheme $token") + .set(headerName, token) .build() ) .build() val result = chain.withRequest(authed).proceed() - // If the server rejected the token, try refreshing once. + // If the server rejected the token, re-validate the session once. if (result.isUnauthorized()) { val newToken = refreshToken() ?: return result val retried = authed.newBuilder() .httpHeaders( authed.httpHeaders.newBuilder() - .set(HEADER_AUTHORIZATION, "$authScheme $newToken") + .set(headerName, newToken) .build() ) .build() // Only retry *once* — whatever comes back from this call is final, // even if it is itself a 401. This guards against an infinite loop - // when refresh succeeds but the backing account is still revoked. + // when the session is still revoked. return chain.withRequest(retried).proceed() } @@ -88,7 +96,6 @@ class CoilAuthInterceptor( } companion object { - private const val HEADER_AUTHORIZATION = "Authorization" private const val HTTP_UNAUTHORIZED = 401 } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ContractorApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ContractorApi.kt index df41af4..d289250 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ContractorApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ContractorApi.kt @@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult> { return try { val response = client.get("$baseUrl/contractors/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) specialty?.let { parameter("specialty", it) } isFavorite?.let { parameter("is_favorite", it) } isActive?.let { parameter("is_active", it) } @@ -38,7 +38,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getContractor(token: String, id: Int): ApiResult { return try { val response = client.get("$baseUrl/contractors/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -54,7 +54,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult { return try { val response = client.post("$baseUrl/contractors/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -77,7 +77,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult { return try { val response = client.patch("$baseUrl/contractors/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -100,7 +100,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteContractor(token: String, id: Int): ApiResult { return try { val response = client.delete("$baseUrl/contractors/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -116,7 +116,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun toggleFavorite(token: String, id: Int): ApiResult { return try { val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -132,7 +132,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getContractorTasks(token: String, id: Int): ApiResult> { return try { val response = client.get("$baseUrl/contractors/$id/tasks/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -148,7 +148,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult> { return try { val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/DocumentApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/DocumentApi.kt index eb07e29..ba2ad3d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/DocumentApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/DocumentApi.kt @@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult> { return try { val response = client.get("$baseUrl/documents/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) residenceId?.let { parameter("residence", it) } documentType?.let { parameter("document_type", it) } isActive?.let { parameter("is_active", it) } @@ -47,7 +47,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getDocument(token: String, id: Int): ApiResult { return try { val response = client.get("$baseUrl/documents/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -127,7 +127,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { } } ) { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } } else { // If no file, use JSON @@ -143,7 +143,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { residenceId = residenceId ) client.post("$baseUrl/documents/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -201,7 +201,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { expiryDate = endDate // Map endDate to expiryDate ) val response = client.patch("$baseUrl/documents/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -224,7 +224,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteDocument(token: String, id: Int): ApiResult { return try { val response = client.delete("$baseUrl/documents/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -240,7 +240,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun downloadDocument(token: String, url: String): ApiResult { return try { val response = client.get(url) { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -256,7 +256,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun activateDocument(token: String, id: Int): ApiResult { return try { val response = client.post("$baseUrl/documents/$id/activate/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -274,7 +274,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deactivateDocument(token: String, id: Int): ApiResult { return try { val response = client.post("$baseUrl/documents/$id/deactivate/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -308,7 +308,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { caption?.let { append("caption", it) } } ) { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -329,7 +329,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult { return try { val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/LookupsApi.kt index 9a22877..a54492e 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/LookupsApi.kt @@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getResidenceTypes(token: String): ApiResult> { return try { val response = client.get("$baseUrl/residences/types/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -52,7 +52,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskFrequencies(token: String): ApiResult> { return try { val response = client.get("$baseUrl/tasks/frequencies/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -68,7 +68,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskPriorities(token: String): ApiResult> { return try { val response = client.get("$baseUrl/tasks/priorities/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -84,7 +84,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskCategories(token: String): ApiResult> { return try { val response = client.get("$baseUrl/tasks/categories/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -100,7 +100,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getContractorSpecialties(token: String): ApiResult> { return try { val response = client.get("$baseUrl/contractors/specialties/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -117,7 +117,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { return try { val response = client.get("$baseUrl/static_data/") { // Token is optional - endpoint is public - token?.let { header("Authorization", "Token $it") } + token?.let { header(SESSION_TOKEN_HEADER, it) } } if (response.status.isSuccess()) { @@ -145,7 +145,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { return try { val response: HttpResponse = client.get("$baseUrl/static_data/") { // Token is optional - endpoint is public - token?.let { header("Authorization", "Token $it") } + token?.let { header(SESSION_TOKEN_HEADER, it) } // Send If-None-Match header for conditional request currentETag?.let { header("If-None-Match", it) } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/NotificationApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/NotificationApi.kt index 9925c5e..6fdfc6a 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/NotificationApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/NotificationApi.kt @@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.post("$baseUrl/notifications/devices/register/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -44,7 +44,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.delete("$baseUrl/notifications/devices/$deviceId/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -63,7 +63,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getNotificationPreferences(token: String): ApiResult { return try { val response = client.get("$baseUrl/notifications/preferences/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -85,7 +85,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.put("$baseUrl/notifications/preferences/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -103,7 +103,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getNotificationHistory(token: String): ApiResult> { return try { val response = client.get("$baseUrl/notifications/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -123,7 +123,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.post("$baseUrl/notifications/$notificationId/read/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -139,7 +139,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun markAllNotificationsAsRead(token: String): ApiResult { return try { val response = client.post("$baseUrl/notifications/mark-all-read/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -155,7 +155,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getUnreadCount(token: String): ApiResult { return try { val response = client.get("$baseUrl/notifications/unread-count/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt index f14ac12..dff98f4 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ResidenceApi.kt @@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getResidences(token: String): ApiResult> { return try { val response = client.get("$baseUrl/residences/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -28,7 +28,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getResidence(token: String, id: Int): ApiResult { return try { val response = client.get("$baseUrl/residences/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -44,7 +44,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult> { return try { val response = client.post("$baseUrl/residences/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -62,7 +62,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult> { return try { val response = client.put("$baseUrl/residences/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -80,7 +80,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteResidence(token: String, id: Int): ApiResult> { return try { val response = client.delete("$baseUrl/residences/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -96,7 +96,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getSummary(token: String): ApiResult { return try { val response = client.get("$baseUrl/residences/summary/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -112,7 +112,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getMyResidences(token: String): ApiResult { return try { val response = client.get("$baseUrl/residences/my-residences/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -129,7 +129,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult { return try { val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -146,7 +146,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun generateShareCode(token: String, residenceId: Int): ApiResult { return try { val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -163,7 +163,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getShareCode(token: String, residenceId: Int): ApiResult { return try { val response = client.get("$baseUrl/residences/$residenceId/share-code/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -186,7 +186,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult { return try { val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -207,7 +207,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult { return try { val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -224,7 +224,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun joinWithCode(token: String, code: String): ApiResult { return try { val response = client.post("$baseUrl/residences/join-with-code/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(JoinResidenceRequest(code)) } @@ -244,7 +244,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult { return try { val response = client.get("$baseUrl/residences/$residenceId/users/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -261,7 +261,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult { return try { val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -279,7 +279,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult { return try { val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) if (email != null) { setBody(mapOf("email" to email)) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/SubscriptionApi.kt index 1c230c6..ebdcbf2 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/SubscriptionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/SubscriptionApi.kt @@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getSubscriptionStatus(token: String): ApiResult { return try { val response = client.get("$baseUrl/subscription/status/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -29,7 +29,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { return try { val response = client.get("$baseUrl/subscription/upgrade-triggers/") { // Token is optional - endpoint is public - token?.let { header("Authorization", "Token $it") } + token?.let { header(SESSION_TOKEN_HEADER, it) } } if (response.status.isSuccess()) { @@ -45,7 +45,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getFeatureBenefits(token: String): ApiResult> { return try { val response = client.get("$baseUrl/subscription/features/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -61,7 +61,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getActivePromotions(token: String): ApiResult> { return try { val response = client.get("$baseUrl/subscription/promotions/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -85,7 +85,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.post("$baseUrl/subscription/purchase/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(mapOf( "platform" to "ios", @@ -115,7 +115,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.post("$baseUrl/subscription/purchase/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(mapOf( "platform" to "android", @@ -154,7 +154,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { } val response = client.post("$baseUrl/subscription/restore/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(body) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt index d18b2f6..61de40a 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskApi.kt @@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.get("$baseUrl/tasks/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) days?.let { parameter("days", it) } } @@ -33,7 +33,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTask(token: String, id: Int): ApiResult { return try { val response = client.get("$baseUrl/tasks/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -50,7 +50,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult> { return try { val response = client.post("$baseUrl/tasks/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -75,7 +75,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult { return try { val response = client.post("$baseUrl/tasks/bulk/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -94,7 +94,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult> { return try { val response = client.put("$baseUrl/tasks/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -113,7 +113,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteTask(token: String, id: Int): ApiResult> { return try { val response = client.delete("$baseUrl/tasks/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -134,7 +134,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) days?.let { parameter("days", it) } } @@ -157,7 +157,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult> { return try { val response = client.patch("$baseUrl/tasks/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -206,7 +206,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult> { return try { val response = client.post("$baseUrl/tasks/$id/$action/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) } when (response.status) { @@ -233,7 +233,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult> { return try { val response = client.get("$baseUrl/tasks/$taskId/completions/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt index 7af3129..40a187c 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt @@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getCompletions(token: String): ApiResult> { return try { val response = client.get("$baseUrl/task-completions/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -29,7 +29,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getCompletion(token: String, id: Int): ApiResult { return try { val response = client.get("$baseUrl/task-completions/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { @@ -45,7 +45,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult> { return try { val response = client.post("$baseUrl/task-completions/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -63,7 +63,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult { return try { val response = client.put("$baseUrl/task-completions/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(request) } @@ -81,7 +81,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun deleteCompletion(token: String, id: Int): ApiResult> { return try { val response = client.delete("$baseUrl/task-completions/$id/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt index 2a25743..7b90dd1 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskTemplateApi.kt @@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult { return try { val response = client.get("$baseUrl/tasks/suggestions/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) parameter("residence_id", residenceId) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt index 97290dd..5bac006 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt @@ -36,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) { ): ApiResult { return try { val response = client.post("$baseUrl/uploads/presign/") { - header("Authorization", "Token $token") + header(SESSION_TOKEN_HEADER, token) contentType(ContentType.Application.Json) setBody(PresignUploadRequest(category, contentType, contentLength)) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AuthenticatedImage.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AuthenticatedImage.kt index f047851..3f8446c 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AuthenticatedImage.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AuthenticatedImage.kt @@ -17,6 +17,7 @@ import coil3.request.ImageRequest import coil3.network.NetworkHeaders import coil3.network.httpHeaders import com.tt.honeyDue.network.ApiClient +import com.tt.honeyDue.network.SESSION_TOKEN_HEADER import com.tt.honeyDue.storage.TokenStorage /** @@ -57,9 +58,11 @@ fun AuthenticatedImage( .data(fullUrl) .apply { if (token != null) { + // honeyDue media is gated on the Kratos session token, + // carried on the X-Session-Token header. httpHeaders( NetworkHeaders.Builder() - .set("Authorization", "Token $token") + .set(SESSION_TOKEN_HEADER, token) .build() ) } diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt index 0e97759..236722f 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt @@ -413,26 +413,30 @@ class HttpClientPluginsTest { client.close() } - // ==================== Token Refresh / 401 Handling Tests ==================== + // ==================== Kratos Session / 401 Handling Tests ==================== @Test - fun testTokenExpiredExceptionIsRefreshed() { + fun testTokenExpiredExceptionStillValid() { + // refreshed = true means the Kratos session was re-validated and is + // still usable — the caller may retry the request. val exception = TokenExpiredException(refreshed = true) assertTrue(exception.refreshed) - assertTrue(exception.message!!.contains("refreshed")) + assertTrue(exception.message!!.contains("still valid")) } @Test - fun testTokenExpiredExceptionNotRefreshed() { + fun testTokenExpiredExceptionSessionGone() { + // refreshed = false means the Kratos session is gone — the user must + // sign in again. val exception = TokenExpiredException(refreshed = false) assertTrue(!exception.refreshed) assertTrue(exception.message!!.contains("re-authenticate")) } @Test - fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest { - // A 401 that does NOT contain "expired" or "token_expired" should NOT - // throw TokenExpiredException — it should just return the 401 response. + fun test401WithoutValidatorReturnsResponse() = runTest { + // Without the Kratos session validator installed, a 401 simply + // surfaces as the 401 response — it does not throw. var requestCount = 0 val client = HttpClient(MockEngine) { engine { diff --git a/iosApp/iosApp/Components/AuthenticatedImage.swift b/iosApp/iosApp/Components/AuthenticatedImage.swift index 6cb172f..719db82 100644 --- a/iosApp/iosApp/Components/AuthenticatedImage.swift +++ b/iosApp/iosApp/Components/AuthenticatedImage.swift @@ -153,9 +153,11 @@ private class AuthenticatedImageLoader: ObservableObject { return } - // Create request with auth header + // Create request with the Kratos session-token header. + // Identity is owned by Ory Kratos; the honeyDue API authenticates + // requests via the session token on X-Session-Token. var request = URLRequest(url: url) - request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + request.setValue(token, forHTTPHeaderField: "X-Session-Token") request.timeoutInterval = 15 request.cachePolicy = .returnCacheDataElseLoad diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index 7ec2c83..52f97da 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -167,9 +167,10 @@ struct DocumentDetailView: View { return } - // Create authenticated request + // Create authenticated request — the honeyDue API gates + // media on the Kratos session token (X-Session-Token). var request = URLRequest(url: url) - request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + request.setValue(token, forHTTPHeaderField: "X-Session-Token") // Download the file let (tempURL, response) = try await URLSession.shared.download(for: request) diff --git a/iosApp/iosApp/Helpers/PresignedUploader.swift b/iosApp/iosApp/Helpers/PresignedUploader.swift index 48d493b..19190e8 100644 --- a/iosApp/iosApp/Helpers/PresignedUploader.swift +++ b/iosApp/iosApp/Helpers/PresignedUploader.swift @@ -170,7 +170,8 @@ final class PresignedUploader { var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") + // honeyDue API auth: Kratos session token on X-Session-Token. + req.setValue(authToken, forHTTPHeaderField: "X-Session-Token") req.httpBody = try JSONEncoder().encode(PresignBody( category: category.rawValue, content_type: contentType,