Skip to content

Commit 9d01ffd

Browse files
committed
feat: unify /ai/v1 auth onto NativeAuth + Android session-expiry handling
Two OSS-side changes that unblock a NativeAuth (Quire Cloud) deployment end-to-end, without the deprecated AI HMAC token mode. Server — /ai/v1/* delegates to the primary AuthBackend under native auth: - Add BackendAiAuthenticator, which maps the configured AuthBackend's AuthUser to an AiPrincipal (subject="native:<id>", tenant_id="local", auth_mode="native"). create_app() builds it when auth_backend=native + ai_auth_mode=basic. - Remove the old crash guard that forbade that combination — it is now the supported native-AI path. ai_auth_mode=token is unchanged and remains for its X-2 deprecation window. CalibreWeb Basic default is byte-for-byte unchanged. - Extend AiPrincipal.auth_mode with "native"; update README + docstrings. Android — Bearer session expiry + re-auth signal (NativeAuth has no refresh endpoint, so the client surfaces re-auth rather than silently refreshing): - Persist expires_at end-to-end: AccountCredentials.Bearer.expiresAtEpochMs (+ isExpiredAt), stored in CalibreCredentialStore, parsed from the login response in ConnectServerViewModel. - CalibreCredentialStore.needsReauth StateFlow, raised by notifyUnauthorized() (a 401 on a same-origin Bearer request, via AccountAuthInterceptor's new non-mutating onBearerUnauthorized callback) and checkSessionExpiry(); reset on save/clear; pre-expired sessions start raised on cold launch. - AppNavGraph routes to the connect screen when the signal flips. The interceptor stays a non-retrying Interceptor (no credential mutation from the network layer). Tests: server suite green (added native-seam unit + integration coverage); Android auth + opds unit tests and assembleDebug green.
1 parent c04196f commit 9d01ffd

16 files changed

Lines changed: 671 additions & 83 deletions

File tree

app/src/main/java/io/theficos/ereader/ui/AppNavGraph.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ fun AppNavGraph(container: AppContainer) {
5959
hasAccount = container.credentialStore.getAccount() != null,
6060
welcomeCompleted = container.welcomePreferencesStore.isCompleted(),
6161
)
62+
// Re-auth gating for NativeAuth (Bearer) sessions. The store raises this
63+
// when the session token expires or a request comes back 401 — there's no
64+
// refresh token, so the only recovery is signing in again. We probe expiry
65+
// once on entry, then route to the connect screen whenever the signal
66+
// flips true mid-session. A successful re-login clears the signal (the
67+
// store resets it on save) and the connect screen navigates onward.
68+
LaunchedEffect(Unit) { container.credentialStore.checkSessionExpiry() }
69+
val needsReauth by container.credentialStore.needsReauth.collectAsState()
70+
LaunchedEffect(needsReauth) {
71+
if (needsReauth) {
72+
nav.navigate("connect-server") { launchSingleTop = true }
73+
}
74+
}
6275
NavHost(navController = nav, startDestination = startDestination) {
6376
composable("welcome") {
6477
WelcomeScreen(

app/src/main/java/io/theficos/ereader/ui/onboarding/ConnectServerViewModel.kt

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import okhttp3.OkHttpClient
2525
import okhttp3.Request
2626
import okhttp3.RequestBody.Companion.toRequestBody
2727
import java.io.IOException
28+
import java.time.OffsetDateTime
2829
import java.util.Base64
2930
import java.util.concurrent.TimeUnit
3031

@@ -112,7 +113,12 @@ class ConnectServerViewModel(
112113
}
113114
_state.value = when (result) {
114115
is VerificationOutcome.OkBearer -> {
115-
credentialStore.saveBearerAccount(canonicalBaseUrl, email, result.token)
116+
credentialStore.saveBearerAccount(
117+
canonicalBaseUrl,
118+
email,
119+
result.token,
120+
result.expiresAtEpochMs,
121+
)
116122
UiState.Completed
117123
}
118124
is VerificationOutcome.OkBasic -> error("unreachable in bearer flow")
@@ -181,13 +187,13 @@ class ConnectServerViewModel(
181187
when (resp.code) {
182188
200 -> {
183189
val body = resp.body?.string().orEmpty()
184-
val token = parseLoginToken(body)
185-
if (token.isNullOrBlank()) {
190+
val parsed = parseLogin(body)
191+
if (parsed == null || parsed.token.isBlank()) {
186192
VerificationOutcome.Failure(
187193
"Server returned 200 but the response didn't include a token.",
188194
)
189195
} else {
190-
VerificationOutcome.OkBearer(token)
196+
VerificationOutcome.OkBearer(parsed.token, parsed.expiresAtEpochMs)
191197
}
192198
}
193199
401 -> VerificationOutcome.Failure("Email or password rejected by the server.")
@@ -204,9 +210,22 @@ class ConnectServerViewModel(
204210
}
205211
}
206212

207-
private fun parseLoginToken(body: String): String? = runCatching {
213+
/** Token plus optional absolute expiry, parsed from a login response. */
214+
private data class ParsedLogin(val token: String, val expiresAtEpochMs: Long?)
215+
216+
/**
217+
* Parse `{"token": ..., "expires_at": ...}` from the login response.
218+
* `expires_at` is an ISO-8601 instant (e.g. `2026-06-28T12:00:00+00:00`
219+
* or `...Z`); an absent or unparseable value yields a null expiry, which
220+
* the credential store treats as "expiry unknown" rather than failing the
221+
* login. Returns null only when the body isn't a JSON object.
222+
*/
223+
private fun parseLogin(body: String): ParsedLogin? = runCatching {
208224
val parsed = Json.parseToJsonElement(body) as? JsonObject ?: return null
209-
(parsed["token"] as? JsonPrimitive)?.content
225+
val token = (parsed["token"] as? JsonPrimitive)?.content ?: return null
226+
val expiresAt = (parsed["expires_at"] as? JsonPrimitive)?.content
227+
?.let { raw -> runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull() }
228+
ParsedLogin(token, expiresAt)
210229
}.getOrNull()
211230

212231
/** UI state. Sealed so the screen can `when`-exhaust render branches. */
@@ -221,7 +240,7 @@ class ConnectServerViewModel(
221240

222241
private sealed class VerificationOutcome {
223242
object OkBasic : VerificationOutcome()
224-
data class OkBearer(val token: String) : VerificationOutcome()
243+
data class OkBearer(val token: String, val expiresAtEpochMs: Long?) : VerificationOutcome()
225244
data class Failure(val message: String) : VerificationOutcome()
226245
}
227246

auth/src/main/java/io/theficos/ereader/auth/AccountCredentials.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,44 @@ sealed class AccountCredentials {
5353
"AccountCredentials.Basic(baseUrl=$baseUrl, username=$username, password=***, quireServerUrl=$quireServerUrl)"
5454
}
5555

56+
/**
57+
* NativeAuth (`quire_server` / Quire Cloud) bearer-token credentials.
58+
*
59+
* [expiresAtEpochMs] is the absolute expiry of the session token, in
60+
* Unix epoch milliseconds, as reported by the server's
61+
* `POST /auth/v1/login` response (`expires_at`). It is **nullable** to
62+
* cover two cases: legacy records persisted before expiry tracking
63+
* existed, and login responses whose `expires_at` we could not parse.
64+
* `null` means "expiry unknown" — callers must treat that as a
65+
* still-usable token (fail-open) rather than as already-expired, since
66+
* NativeAuth has no refresh endpoint and a spurious logout would be worse
67+
* than letting the next request surface a real 401.
68+
*
69+
* NativeAuth issues opaque, non-refreshable session tokens, so when this
70+
* instant passes the only recovery is a fresh interactive login. See
71+
* [isExpiredAt].
72+
*/
5673
data class Bearer(
5774
override val baseUrl: String,
5875
val email: String,
5976
val token: String,
77+
val expiresAtEpochMs: Long? = null,
6078
) : AccountCredentials() {
6179
override val scheme: AuthScheme = AuthScheme.BEARER
6280
override val subject: String get() = email.lowercase()
81+
82+
/**
83+
* True when this session's [expiresAtEpochMs] is known and is at or
84+
* before [nowEpochMs]. Returns false when expiry is unknown
85+
* (fail-open — see the [expiresAtEpochMs] doc).
86+
*/
87+
fun isExpiredAt(nowEpochMs: Long): Boolean {
88+
val exp = expiresAtEpochMs ?: return false
89+
return nowEpochMs >= exp
90+
}
91+
6392
override fun toString(): String =
64-
"AccountCredentials.Bearer(baseUrl=$baseUrl, email=$email, token=***)"
93+
"AccountCredentials.Bearer(baseUrl=$baseUrl, email=$email, token=***, " +
94+
"expiresAtEpochMs=$expiresAtEpochMs)"
6595
}
6696
}

auth/src/main/java/io/theficos/ereader/auth/CalibreCredentialStore.kt

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,37 @@ class CalibreCredentialStore(context: Context) {
4747

4848
private val _accountFlow: MutableStateFlow<AccountCredentials?>
4949
private val _flow: MutableStateFlow<CalibreCredentials?>
50+
private val _needsReauth: MutableStateFlow<Boolean>
5051

5152
init {
5253
val initial = loadAccount()
5354
_accountFlow = MutableStateFlow(initial)
5455
_flow = MutableStateFlow(initial.asLegacyBasicOrNull())
56+
// A stored Bearer session whose expiry has already passed on cold
57+
// launch starts in the re-auth state, so the UI can route the user
58+
// straight to sign-in instead of letting the first request 401.
59+
_needsReauth = MutableStateFlow(
60+
(initial as? AccountCredentials.Bearer)?.isExpiredAt(System.currentTimeMillis()) == true
61+
)
5562
}
5663

64+
/**
65+
* Re-auth signal for the configured Bearer (NativeAuth) account.
66+
*
67+
* Emits `true` when the current session can no longer authenticate and
68+
* the user must sign in again — either because its `expires_at` has
69+
* passed ([checkSessionExpiry]) or because a request came back `401`
70+
* ([notifyUnauthorized]). NativeAuth has no refresh endpoint, so there is
71+
* nothing to silently retry; the only recovery is a fresh interactive
72+
* login, which the onboarding flow drives.
73+
*
74+
* This is a read-only *signal*: it never mutates or clears the stored
75+
* credential. Saving a new account (a successful re-login) or clearing
76+
* the store resets it to `false`. Basic accounts never set it — a 401
77+
* there is a wrong-password condition handled by the onboarding verifier.
78+
*/
79+
val needsReauth: StateFlow<Boolean> = _needsReauth.asStateFlow()
80+
5781
/**
5882
* Scheme-aware account observable. New callers (sync, library, AI,
5983
* catalog) should prefer this over [flow] so BEARER accounts surface a
@@ -119,8 +143,20 @@ class CalibreCredentialStore(context: Context) {
119143
* Save a `quire_server` / Quire Cloud bearer-token account. The token is
120144
* stored as-is; callers must pass the raw token (no leading "Bearer ").
121145
* Blank values and tokens containing whitespace are rejected.
146+
*
147+
* [expiresAtEpochMs] is the session expiry from the login response's
148+
* `expires_at`, in Unix epoch milliseconds; pass null when unknown (the
149+
* session is then treated as non-expiring locally — see
150+
* [AccountCredentials.Bearer.expiresAtEpochMs]). A successful save clears
151+
* any pending [needsReauth] signal, since persisting a token is exactly
152+
* what a re-login does.
122153
*/
123-
fun saveBearerAccount(baseUrl: String, email: String, token: String) {
154+
fun saveBearerAccount(
155+
baseUrl: String,
156+
email: String,
157+
token: String,
158+
expiresAtEpochMs: Long? = null,
159+
) {
124160
require(baseUrl.isNotBlank()) { "baseUrl must not be blank" }
125161
require(email.isNotBlank()) { "email must not be blank" }
126162
require(token.isNotBlank()) { "token must not be blank" }
@@ -131,10 +167,46 @@ class CalibreCredentialStore(context: Context) {
131167
baseUrl = baseUrl.trim().trimEnd('/'),
132168
email = email,
133169
token = token,
170+
expiresAtEpochMs = expiresAtEpochMs,
134171
)
135172
persistAccount(account)
136173
}
137174

175+
/**
176+
* Record that an authenticated request to the configured account's origin
177+
* came back `401`. Flips [needsReauth] to `true` **only** when the current
178+
* account is Bearer (NativeAuth) — there is no token to refresh, so the
179+
* user must sign in again. Never mutates or clears the stored credential
180+
* (the network layer must not silently swap or drop credentials); it only
181+
* raises the signal the UI observes. No-op for Basic accounts and when no
182+
* account is configured.
183+
*/
184+
fun notifyUnauthorized() {
185+
synchronized(mutationLock) {
186+
if (_accountFlow.value is AccountCredentials.Bearer) {
187+
_needsReauth.value = true
188+
}
189+
}
190+
}
191+
192+
/**
193+
* Proactively evaluate the configured Bearer session's expiry against
194+
* [nowEpochMs] and raise [needsReauth] if it has lapsed. Callers (e.g. the
195+
* UI on resume / cold launch) invoke this so an already-expired session
196+
* routes to sign-in without first firing a doomed network request.
197+
* Returns the resulting [needsReauth] value. No-op effect for non-Bearer
198+
* accounts or sessions with unknown expiry.
199+
*/
200+
fun checkSessionExpiry(nowEpochMs: Long = System.currentTimeMillis()): Boolean {
201+
synchronized(mutationLock) {
202+
val account = _accountFlow.value
203+
if (account is AccountCredentials.Bearer && account.isExpiredAt(nowEpochMs)) {
204+
_needsReauth.value = true
205+
}
206+
return _needsReauth.value
207+
}
208+
}
209+
138210
/** Clear all stored credentials. */
139211
fun clear() {
140212
synchronized(mutationLock) {
@@ -144,6 +216,7 @@ class CalibreCredentialStore(context: Context) {
144216
}
145217
_accountFlow.value = null
146218
_flow.value = null
219+
_needsReauth.value = false
147220
}
148221
}
149222

@@ -162,6 +235,7 @@ class CalibreCredentialStore(context: Context) {
162235
.putString(KEY_PASS, account.password)
163236
.remove(KEY_EMAIL)
164237
.remove(KEY_TOKEN)
238+
.remove(KEY_EXPIRES_AT)
165239
if (account.quireServerUrl != null) {
166240
editor.putString(KEY_QUIRE_SERVER_URL, account.quireServerUrl)
167241
} else {
@@ -175,6 +249,11 @@ class CalibreCredentialStore(context: Context) {
175249
.remove(KEY_USER)
176250
.remove(KEY_PASS)
177251
.remove(KEY_QUIRE_SERVER_URL)
252+
if (account.expiresAtEpochMs != null) {
253+
editor.putLong(KEY_EXPIRES_AT, account.expiresAtEpochMs)
254+
} else {
255+
editor.remove(KEY_EXPIRES_AT)
256+
}
178257
}
179258
}
180259
val committed = editor.commit()
@@ -187,6 +266,9 @@ class CalibreCredentialStore(context: Context) {
187266
}
188267
_accountFlow.value = account
189268
_flow.value = account.asLegacyBasicOrNull()
269+
// A fresh save is a (re-)authentication: any pending re-auth
270+
// signal is now satisfied.
271+
_needsReauth.value = false
190272
}
191273
}
192274

@@ -227,7 +309,14 @@ class CalibreCredentialStore(context: Context) {
227309
AuthScheme.BEARER -> {
228310
val email = prefs.getString(KEY_EMAIL, null) ?: return null
229311
val token = prefs.getString(KEY_TOKEN, null) ?: return null
230-
AccountCredentials.Bearer(baseUrl, email, token)
312+
// Absent key → unknown expiry (legacy record or unparseable
313+
// login response). Stored as a plain Long when known.
314+
val expiresAt = if (prefs.contains(KEY_EXPIRES_AT)) {
315+
prefs.getLong(KEY_EXPIRES_AT, 0L)
316+
} else {
317+
null
318+
}
319+
AccountCredentials.Bearer(baseUrl, email, token, expiresAt)
231320
}
232321
}
233322
}
@@ -245,6 +334,7 @@ class CalibreCredentialStore(context: Context) {
245334
const val KEY_PASS = "password"
246335
const val KEY_EMAIL = "email"
247336
const val KEY_TOKEN = "token"
337+
const val KEY_EXPIRES_AT = "expires_at_epoch_ms"
248338
const val KEY_QUIRE_SERVER_URL = "quire_server_url"
249339
}
250340
}

0 commit comments

Comments
 (0)