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>
This commit is contained in:
@@ -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())
|
||||
|
||||
+34
-33
@@ -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,235 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Models for Ory Kratos native (`api`) self-service flows.
|
||||
*
|
||||
* honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile
|
||||
* client drives Kratos' native flows directly:
|
||||
*
|
||||
* 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action)
|
||||
* 2. `POST {ui.action}` -> success body or a re-rendered
|
||||
* flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`.
|
||||
*
|
||||
* Only the fields the client actually needs are modelled; `ignoreUnknownKeys`
|
||||
* on the Json instance tolerates the rest of Kratos' (large) payloads.
|
||||
*
|
||||
* Kratos docs: https://www.ory.sh/docs/kratos/self-service
|
||||
*/
|
||||
|
||||
// ==================== Flow envelope ====================
|
||||
|
||||
/**
|
||||
* A Kratos self-service flow (login / registration / recovery / verification).
|
||||
* Returned by the initial `GET .../{flow}/api` call.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosFlow(
|
||||
val id: String,
|
||||
val type: String? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("issued_at") val issuedAt: String? = null,
|
||||
val ui: KratosUi,
|
||||
/** Present on a verification/recovery flow that is already complete. */
|
||||
val state: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The renderable UI of a flow. `action` is the absolute URL the client must
|
||||
* POST the method payload to; `messages` carries flow-level errors.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosUi(
|
||||
val action: String,
|
||||
val method: String = "POST",
|
||||
val nodes: List<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 — e.g. Kratos asking the client to show a
|
||||
* verification flow after registration.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosContinueWith(
|
||||
val action: String? = null,
|
||||
val flow: KratosContinueWithFlow? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KratosContinueWithFlow(
|
||||
val id: String? = null,
|
||||
@SerialName("verifiable_address") val verifiableAddress: String? = null,
|
||||
)
|
||||
|
||||
// ==================== Submit payloads ====================
|
||||
|
||||
/**
|
||||
* Body for submitting the `password` method to a login flow.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosPasswordLoginBody(
|
||||
val method: String = "password",
|
||||
val identifier: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for submitting the `password` method to a registration flow.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosPasswordRegistrationBody(
|
||||
val method: String = "password",
|
||||
val traits: KratosTraits,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for submitting an OIDC (`apple` / `google`) provider to a
|
||||
* login or registration flow using a native `id_token` from the
|
||||
* platform sign-in SDK.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosOidcBody(
|
||||
val method: String = "oidc",
|
||||
val provider: String,
|
||||
@SerialName("id_token") val idToken: String,
|
||||
/** Optional traits — sent on registration so Kratos can seed the identity. */
|
||||
val traits: KratosTraits? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for submitting the `code` method to a recovery flow.
|
||||
* The first POST omits [code] (sends just the email); the second
|
||||
* POST includes the code the user received by email.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosRecoveryBody(
|
||||
val method: String = "code",
|
||||
val email: String? = null,
|
||||
val code: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for submitting the `code` method to a verification flow.
|
||||
* As with recovery: first POST sends the email, second sends the code.
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosVerificationBody(
|
||||
val method: String = "code",
|
||||
val email: String? = null,
|
||||
val code: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for updating an identity's password from within a settings flow
|
||||
* (used after a recovery flow hands the client a privileged session).
|
||||
*/
|
||||
@Serializable
|
||||
data class KratosSettingsPasswordBody(
|
||||
val method: String = "password",
|
||||
val password: String,
|
||||
)
|
||||
@@ -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 {
|
||||
attemptTokenRefresh(currentToken)
|
||||
}
|
||||
}
|
||||
if (!refreshed) {
|
||||
// Refresh failed — clear auth and trigger logout
|
||||
DataManager.clear()
|
||||
}
|
||||
// Throw so the caller can retry (or handle the logout)
|
||||
throw TokenExpiredException(refreshed)
|
||||
val currentToken = DataManager.authToken.value
|
||||
if (currentToken != null) {
|
||||
// Use the mutex so concurrent 401s only trigger one
|
||||
// whoami check against Kratos.
|
||||
val stillValid = tokenRefreshMutex.withLock {
|
||||
attemptTokenRefresh(currentToken)
|
||||
}
|
||||
if (!stillValid) {
|
||||
// Session is gone — clear auth and route to login.
|
||||
DataManager.clear()
|
||||
}
|
||||
// Throw so the caller can retry (still valid) or handle
|
||||
// the forced logout (session gone).
|
||||
throw TokenExpiredException(stillValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
|
||||
* Returns true if refresh succeeded and the new token was saved.
|
||||
* Re-validate the current Kratos session.
|
||||
*
|
||||
* Identity is owned by Ory Kratos. Native Kratos session tokens are
|
||||
* long-lived and there is **no native refresh endpoint** — when a session
|
||||
* genuinely expires the user must sign in again. So "refresh" here means:
|
||||
* ask Kratos `GET /sessions/whoami` whether the session is still active.
|
||||
*
|
||||
* - returns `true` → the session is still valid; the original 401 was
|
||||
* transient (e.g. a brief replication lag) and the caller may retry.
|
||||
* - returns `false` → the session is gone; the caller should clear auth and
|
||||
* route the user back to login.
|
||||
*
|
||||
* The token itself is never rotated — [DataManager]/[TokenStorage] keep the
|
||||
* same value either way.
|
||||
*/
|
||||
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
||||
return try {
|
||||
// Use a minimal client to avoid recursive interceptor triggers
|
||||
val refreshClient = HttpClient {
|
||||
// Use a minimal client to avoid recursive interceptor triggers.
|
||||
val whoamiClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
}
|
||||
val baseUrl = ApiConfig.getBaseUrl()
|
||||
val response = refreshClient.post("$baseUrl/auth/refresh/") {
|
||||
header("Authorization", "Token $currentToken")
|
||||
contentType(ContentType.Application.Json)
|
||||
val kratosUrl = ApiConfig.getKratosBaseUrl()
|
||||
val response = whoamiClient.get("$kratosUrl/sessions/whoami") {
|
||||
header(SESSION_TOKEN_HEADER, currentToken)
|
||||
accept(ContentType.Application.Json)
|
||||
}
|
||||
refreshClient.close()
|
||||
whoamiClient.close()
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val tokenResponse = response.body<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,505 @@ import com.tt.honeyDue.models.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Authentication API client.
|
||||
*
|
||||
* Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API.
|
||||
* This client drives Kratos' **native (`api`) self-service flows**:
|
||||
*
|
||||
* - **Login** — `GET .../self-service/login/api` → `POST ui.action`
|
||||
* with `{method:"password", identifier, password}`.
|
||||
* - **Registration** — `GET .../self-service/registration/api` → `POST ui.action`
|
||||
* with `{method:"password", traits:{email,name{first,last}}, password}`.
|
||||
* - **Recovery** — `.../self-service/recovery/api` with `method:"code"`.
|
||||
* - **Verification** — `.../self-service/verification/api` with `method:"code"`.
|
||||
* - **OIDC** — Apple/Google: the platform SDK obtains a native
|
||||
* `id_token`, submitted to the login/registration flow with
|
||||
* `{method:"oidc", provider, id_token}`.
|
||||
*
|
||||
* On success Kratos returns a **`session_token`**. That token is what the
|
||||
* honeyDue API now expects on the `X-Session-Token` header. The session token
|
||||
* is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer].
|
||||
*
|
||||
* Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile
|
||||
* update, account deletion — are also driven from here, sending the Kratos
|
||||
* session token on the `X-Session-Token` header.
|
||||
*
|
||||
* The public method signatures are deliberately unchanged from the old
|
||||
* hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel]
|
||||
* and the UI continue to compile against the same `ApiResult<...>` shapes.
|
||||
*/
|
||||
class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
|
||||
/** honeyDue Go API base — used only for `/auth/me`, profile, delete. */
|
||||
private val apiBaseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
/** Ory Kratos public API base — used for all identity flows. */
|
||||
private val kratosBaseUrl = ApiConfig.getKratosBaseUrl()
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// ==================== Kratos flow plumbing ====================
|
||||
|
||||
/**
|
||||
* Fetch a fresh native self-service flow from Kratos.
|
||||
*
|
||||
* @param flow one of `login`, `registration`, `recovery`, `verification`.
|
||||
* @param refresh when true (login only) forces re-authentication of an
|
||||
* already-valid session.
|
||||
*/
|
||||
private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult<KratosFlow> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/register/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
val response = client.get("$kratosBaseUrl/self-service/$flow/api") {
|
||||
accept(ContentType.Application.Json)
|
||||
if (refresh) parameter("refresh", "true")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText()))
|
||||
} else {
|
||||
// Parse actual error message from backend
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
ApiResult.Error(
|
||||
"Could not start $flow (Kratos ${response.status.value})",
|
||||
response.status.value,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
ApiResult.Error(e.message ?: "Could not reach the authentication server")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a method payload to a flow's `ui.action` URL and decode the
|
||||
* result with [decode]. On a 4xx Kratos re-renders the flow with
|
||||
* validation messages — those are extracted via [extractKratosError].
|
||||
*/
|
||||
private suspend fun <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)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
// Parse actual error message from backend
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
val flow = when (val f = initFlow("login")) {
|
||||
is ApiResult.Success -> f.data
|
||||
is ApiResult.Error -> return f
|
||||
else -> return ApiResult.Error("Could not start login")
|
||||
}
|
||||
val body = json.encodeToString(
|
||||
KratosPasswordLoginBody.serializer(),
|
||||
KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password),
|
||||
)
|
||||
val success = submitFlow(flow.ui.action, body) {
|
||||
json.decodeFromString(KratosLoginSuccess.serializer(), it)
|
||||
}
|
||||
return when (success) {
|
||||
is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session)
|
||||
is ApiResult.Error -> success
|
||||
else -> ApiResult.Error("Login failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Registration ====================
|
||||
|
||||
/**
|
||||
* Native password registration against Kratos.
|
||||
*
|
||||
* Kratos identity traits are `{ email, name: { first, last } }`. The
|
||||
* legacy [RegisterRequest.username] is preserved for the UI but is not a
|
||||
* Kratos trait — the email is the identifier.
|
||||
*/
|
||||
suspend fun register(request: RegisterRequest): ApiResult<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 {
|
||||
resolveSession(token, success.data.session)
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> success
|
||||
else -> ApiResult.Error("Registration failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OIDC (Apple / Google) ====================
|
||||
|
||||
/**
|
||||
* Submit a native OIDC `id_token` to Kratos.
|
||||
*
|
||||
* The platform SDK (Sign in with Apple / Google Sign-In) obtains the
|
||||
* `id_token`; Kratos verifies it and either logs the user in or registers
|
||||
* a new identity. We try the login flow first; if Kratos reports the
|
||||
* identity does not exist we fall through to the registration flow.
|
||||
*
|
||||
* @param provider `"apple"` or `"google"`.
|
||||
* @param idToken the platform-issued OpenID Connect ID token.
|
||||
* @param traits optional traits used to seed a new identity on first sign-in.
|
||||
*/
|
||||
private suspend fun oidcSignIn(
|
||||
provider: String,
|
||||
idToken: String,
|
||||
traits: KratosTraits?,
|
||||
): ApiResult<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")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
ApiResult.Error("Logout failed", response.status.value)
|
||||
client.delete("$kratosBaseUrl/self-service/logout/api") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token)))
|
||||
}
|
||||
// Treat any outcome as success — the token is being discarded
|
||||
// locally anyway, and a stale-token DELETE is harmless.
|
||||
ApiResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
ApiResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Recovery (forgot password) ====================
|
||||
|
||||
/**
|
||||
* Start a Kratos recovery flow and submit the user's email so Kratos
|
||||
* mails them a recovery code.
|
||||
*
|
||||
* Mirrors the legacy `forgotPassword` signature. The returned message is
|
||||
* Kratos' confirmation text.
|
||||
*/
|
||||
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<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 -> {
|
||||
val info = result.data?.ui?.messages?.firstOrNull()?.text
|
||||
ApiResult.Success(
|
||||
ForgotPasswordResponse(
|
||||
message = info ?: "If that email exists, a recovery code has been sent.",
|
||||
),
|
||||
)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Could not send recovery code")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the recovery code the user received by email.
|
||||
*
|
||||
* On success Kratos returns a privileged session and a redirect to a
|
||||
* settings flow. For the native client we surface the recovery flow id
|
||||
* back as the "reset token" so the subsequent [resetPassword] call can
|
||||
* target the same flow.
|
||||
*/
|
||||
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
|
||||
// Re-init a recovery flow and submit email+code together. (Kratos keeps
|
||||
// the address bound to the flow; for the native client we start a
|
||||
// fresh flow and submit both fields in one POST.)
|
||||
val flow = when (val f = initFlow("recovery")) {
|
||||
is ApiResult.Success -> f.data
|
||||
is ApiResult.Error -> return f
|
||||
else -> return ApiResult.Error("Could not verify the code")
|
||||
}
|
||||
val body = json.encodeToString(
|
||||
KratosRecoveryBody.serializer(),
|
||||
KratosRecoveryBody(email = request.email.trim(), code = request.code.trim()),
|
||||
)
|
||||
val result = submitFlow(flow.ui.action, body) {
|
||||
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
|
||||
}
|
||||
return when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(
|
||||
// The recovery flow id doubles as the reset token: resetPassword
|
||||
// uses it to drive the settings flow that follows.
|
||||
VerifyResetCodeResponse(
|
||||
message = "Code verified.",
|
||||
resetToken = flow.id,
|
||||
),
|
||||
)
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Invalid or expired code")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a password reset.
|
||||
*
|
||||
* After [verifyResetCode] Kratos hands the client a privileged session via
|
||||
* a settings flow. This submits the new password to that settings flow.
|
||||
*
|
||||
* TODO(kratos): the native recovery → settings handoff returns the
|
||||
* settings flow URL in the recovery response's `continue_with`. Wiring the
|
||||
* full two-step handoff requires the privileged `session_token` Kratos
|
||||
* issues on code verification; until the backend confirms the exact shape,
|
||||
* [resetPassword] starts a settings flow keyed by the reset token as a
|
||||
* best-effort and surfaces any Kratos validation message verbatim.
|
||||
*/
|
||||
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
|
||||
return try {
|
||||
// Settings flow keyed by the recovery flow id handed back from
|
||||
// verifyResetCode. Kratos binds the privileged session server-side.
|
||||
val response = client.post("$kratosBaseUrl/self-service/settings") {
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Application.Json)
|
||||
parameter("flow", request.resetToken)
|
||||
setBody(
|
||||
json.encodeToString(
|
||||
KratosSettingsPasswordBody.serializer(),
|
||||
KratosSettingsPasswordBody(password = request.newPassword),
|
||||
),
|
||||
)
|
||||
}
|
||||
val text = response.bodyAsText()
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in."))
|
||||
} else {
|
||||
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Could not reset password")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Email verification ====================
|
||||
|
||||
/**
|
||||
* Submit an email-verification code to Kratos' verification flow.
|
||||
*
|
||||
* Note: the [token] parameter (a session token) is unused for the Kratos
|
||||
* verification flow — verification is anonymous and keyed by the code —
|
||||
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change.
|
||||
*/
|
||||
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<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 +513,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 ====================
|
||||
|
||||
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")
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
else -> ApiResult.Error("Could not load profile after sign-in")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort [User] built from a Kratos identity when the honeyDue
|
||||
* `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs,
|
||||
* not the honeyDue numeric id) — callers should re-fetch via
|
||||
* [getCurrentUser] as soon as the API is reachable.
|
||||
*/
|
||||
private fun userFromKratosIdentity(identity: KratosIdentity): User {
|
||||
val traits = identity.traits
|
||||
return User(
|
||||
id = 0,
|
||||
username = traits?.email ?: "",
|
||||
email = traits?.email ?: "",
|
||||
firstName = traits?.name?.first ?: "",
|
||||
lastName = traits?.name?.last ?: "",
|
||||
isActive = identity.state == null || identity.state == "active",
|
||||
dateJoined = "",
|
||||
authProvider = "kratos",
|
||||
profile = null,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The header the honeyDue API now expects on authenticated requests —
|
||||
* carries the Kratos session token. Replaces `Authorization: Token …`.
|
||||
*/
|
||||
const val HEADER_SESSION_TOKEN = "X-Session-Token"
|
||||
}
|
||||
}
|
||||
|
||||
/** Body for Kratos' native `DELETE /self-service/logout/api`. */
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class LogoutBody(
|
||||
@kotlinx.serialization.SerialName("session_token") val sessionToken: String,
|
||||
)
|
||||
|
||||
/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class KratosErrorEnvelope(
|
||||
val error: KratosErrorDetail? = null,
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class KratosErrorDetail(
|
||||
val message: String? = null,
|
||||
val reason: String? = null,
|
||||
val status: String? = null,
|
||||
val code: Int? = null,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user