2 Commits

Author SHA1 Message Date
Trey t 90a1d98322 fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:49 -05:00
Trey t 05cc4311a7 Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API
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 <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) <noreply@anthropic.com>
2026-05-18 18:21:32 -05:00
24 changed files with 1142 additions and 410 deletions
@@ -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())
@@ -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: <scheme> <token>` 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: <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"])
}
}
@@ -0,0 +1,244 @@
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,
/**
* Post-submission instructions. On a completed recovery flow this carries
* the privileged session token (`set_ory_session_token`) and the settings
* flow to finish the password change in (`show_settings_ui`).
*/
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* 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<KratosUiNode> = emptyList(),
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNode(
val type: String? = null,
val group: String? = null,
val attributes: KratosUiNodeAttributes? = null,
val messages: List<KratosMessage> = 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<KratosVerifiableAddress>? = 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<KratosContinueWith> = emptyList(),
)
/**
* A `continue_with` item. `action` is one of `show_verification_ui`,
* `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
* for `set_ory_session_token` (the privileged session a recovery flow issues).
*/
@Serializable
data class KratosContinueWith(
val action: String? = null,
val flow: KratosContinueWithFlow? = null,
@SerialName("ory_session_token") val orySessionToken: String? = 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,
)
@@ -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(
@@ -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<String> {
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")
}
@@ -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 <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 {
// Use the mutex so concurrent 401s only trigger one
// whoami check against Kratos.
val stillValid = tokenRefreshMutex.withLock {
attemptTokenRefresh(currentToken)
}
}
if (!refreshed) {
// Refresh failed — clear auth and trigger logout
if (!stillValid) {
// Session is gone — clear auth and route to login.
DataManager.clear()
}
// Throw so the caller can retry (or handle the logout)
throw TokenExpiredException(refreshed)
}
// 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<TokenRefreshResponse>()
// 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()}")
}
}
@@ -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
*/
@@ -4,71 +4,533 @@ 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<AuthResponse> {
return try {
val response = client.post("$baseUrl/auth/register/") {
contentType(ContentType.Application.Json)
setBody(request)
/** 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
}
/**
* Action URL of the recovery flow started by [forgotPassword]. Kratos
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
* must submit the code back to the SAME flow — not a fresh one. Held in
* memory only: a process restart between the two steps simply means the
* user requests a new code (recovery flows are short-lived regardless).
*/
private var pendingRecoveryAction: String? = null
// ==================== 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<KratosFlow> {
return try {
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 <T> submitFlow(
actionUrl: String,
bodyJson: String,
decode: (String) -> T,
): ApiResult<T> {
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<AuthResponse> {
return try {
val response = client.post("$baseUrl/auth/login/") {
contentType(ContentType.Application.Json)
setBody(request)
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")
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
// ==================== 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<AuthResponse> {
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 {
// Parse actual error message from backend
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
resolveSession(token, success.data.session)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
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<AuthResponse> {
// 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<AppleSignInResponse> {
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<GoogleSignInResponse> {
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<Unit> {
return try {
val response = client.post("$baseUrl/auth/logout/") {
header("Authorization", "Token $token")
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.Success(Unit)
}
}
if (response.status.isSuccess()) {
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<ForgotPasswordResponse> {
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 -> {
// Remember this flow's action — verifyResetCode submits the
// emailed code back to the SAME flow Kratos bound it to.
pendingRecoveryAction = flow.ui.action
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.
*
* The code is submitted back to the SAME recovery flow [forgotPassword]
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
* returns, via `continue_with`, the privileged session token plus the
* settings flow to finish the password change in. Both are packed into the
* opaque `resetToken` that [resetPassword] consumes.
*/
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
val action = pendingRecoveryAction
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(code = request.code.trim()),
)
val result = submitFlow(action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val flow = result.data
val settingsFlowId = flow?.continueWith
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
val sessionToken = flow?.continueWith
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
if (settingsFlowId != null && sessionToken != null) {
pendingRecoveryAction = null
ApiResult.Success(
VerifyResetCodeResponse(
message = "Code verified.",
// Opaque to the UI: carries the settings flow id and
// the privileged session token resetPassword needs,
// packed as "<settingsFlowId>|<sessionToken>".
resetToken = "$settingsFlowId|$sessionToken",
),
)
} else {
ApiResult.Error("Logout failed", response.status.value)
// No continue_with → the code was wrong or the flow expired;
// surface Kratos' own validation message when present.
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: flow?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code")
}
}
is ApiResult.Error -> result
else -> ApiResult.Error("Invalid or expired code")
}
}
/**
* Complete a password reset.
*
* [verifyResetCode] packed the settings flow id and the privileged session
* token Kratos issued into [ResetPasswordRequest.resetToken] as
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
* that settings flow, authenticating with the privileged session token.
*/
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
val parts = request.resetToken.split("|", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return ApiResult.Error("This password reset session has expired. Request a new code.")
}
val settingsFlowId = parts[0]
val sessionToken = parts[1]
return try {
val response = client.post("$kratosBaseUrl/self-service/settings") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
// The privileged session Kratos issued on code verification
// authorizes the settings flow that changes the password.
header("X-Session-Token", sessionToken)
parameter("flow", settingsFlowId)
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 ?: "Unknown error occurred")
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<VerifyEmailResponse> {
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<User> {
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 +541,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
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<Map<String, String>>()
} 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<User> {
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<Map<String, String>>()
} 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<ForgotPasswordResponse> {
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<Map<String, String>>()
} 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<VerifyResetCodeResponse> {
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<Map<String, String>>()
} 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<ResetPasswordResponse> {
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<String, List<String>>)
val errorMessage = try {
val validationErrors = response.body<Map<String, List<String>>>()
// 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<Map<String, String>>()
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<AppleSignInResponse> {
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<Map<String, String>>()
} 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<Unit> {
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<TokenRefreshResponse> {
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<GoogleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/google-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
// ==================== Session → User resolution ====================
/**
* 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<AuthResponse> {
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
}
}
else -> ApiResult.Error("Could not load profile after sign-in")
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Google Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
/**
* 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,
)
@@ -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 `<scheme> ` 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
}
}
@@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<ContractorSummary>> {
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<Contractor> {
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<Contractor> {
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<Contractor> {
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<Unit> {
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<Contractor> {
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<List<TaskResponse>> {
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<List<ContractorSummary>> {
return try {
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<Document>> {
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<Document> {
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<Unit> {
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<ByteArray> {
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<Document> {
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<Document> {
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<Document> {
return try {
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
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<List<TaskFrequency>> {
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<List<TaskPriority>> {
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<List<TaskCategory>> {
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<List<ContractorSpecialty>> {
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) }
}
@@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<DeviceRegistrationResponse> {
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<Unit> {
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<NotificationPreference> {
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<NotificationPreference> {
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<List<Notification>> {
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<MessageResponse> {
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<MessageResponse> {
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<UnreadCountResponse> {
return try {
val response = client.get("$baseUrl/notifications/unread-count/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
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<ResidenceResponse> {
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<WithSummaryResponse<ResidenceResponse>> {
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<WithSummaryResponse<ResidenceResponse>> {
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<WithSummaryResponse<String>> {
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<TotalSummary> {
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<MyResidencesResponse> {
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<SharedResidence> {
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<GenerateShareCodeResponse> {
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<ShareCodeResponse> {
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<Unit> {
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<Unit> {
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<JoinResidenceResponse> {
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<ResidenceUsersResponse> {
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<RemoveUserResponse> {
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<GenerateReportResponse> {
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))
@@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
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<List<FeatureBenefit>> {
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<List<Promotion>> {
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<VerificationResponse> {
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<VerificationResponse> {
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)
}
@@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> {
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<TaskResponse> {
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<WithSummaryResponse<TaskResponse>> {
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<BulkCreateTasksResponse> {
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<WithSummaryResponse<TaskResponse>> {
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<WithSummaryResponse<String>> {
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<TaskColumnsResponse> {
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<WithSummaryResponse<TaskResponse>> {
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<WithSummaryResponse<TaskResponse>> {
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<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
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<TaskCompletionResponse> {
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<WithSummaryResponse<TaskCompletionResponse>> {
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<TaskCompletionResponse> {
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<WithSummaryResponse<String>> {
return try {
val response = client.delete("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
}
if (response.status.isSuccess()) {
@@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try {
val response = client.get("$baseUrl/tasks/suggestions/") {
header("Authorization", "Token $token")
header(SESSION_TOKEN_HEADER, token)
parameter("residence_id", residenceId)
}
@@ -36,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<PresignUploadResponse> {
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))
}
@@ -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()
)
}
@@ -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 {
@@ -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
@@ -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)
@@ -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,