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:
+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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user