fix(providers): accept numeric expires_at in OAuth status (#1407)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 13:30:41 +03:00
1 parent 6810ef02fe
commit 48de25985a
2 files changed
+61 -3

No files matched your search

@@ -1,7 +1,17 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
/**
* OAuth provider catalog entry returned by `GET /api/providers/oauth`.
@@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
*
* `expires_at` in the status comes back as an ISO timestamp string.
* `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the
* credential source, so they decode through [LenientStringSerializer].
*/
@Serializable
data class OAuthProvider(
@@ -31,9 +42,13 @@ data class OAuthProviderStatus(
val source: String? = null,
@SerialName("source_label") val sourceLabel: String? = null,
@SerialName("token_preview") val tokenPreview: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("expires_at")
@Serializable(with = LenientStringSerializer::class)
val expiresAt: String? = null,
@SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null,
@SerialName("last_refresh") val lastRefresh: String? = null,
@SerialName("last_refresh")
@Serializable(with = LenientStringSerializer::class)
val lastRefresh: String? = null,
val error: String? = null,
)
@@ -98,3 +113,25 @@ data class OAuthCancelResponse(
@SerialName("session_id") val sessionId: String? = null,
val message: String? = null,
)
/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */
@OptIn(ExperimentalSerializationApi::class)
object LenientStringSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): String? {
if (decoder is JsonDecoder) {
val element = decoder.decodeJsonElement()
return if (element is JsonPrimitive && element !is JsonNull) element.content else null
}
return decoder.decodeString()
}
override fun serialize(
encoder: Encoder,
value: String?,
) {
if (value == null) encoder.encodeNull() else encoder.encodeString(value)
}
}
@@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest {
assertEquals(404, response.code())
}
@Test
fun getOAuthProviders_toleratesNumericExpiresAt() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{"providers":[{"id":"codex","name":"Codex","flow":"external",
"status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]}
""".trimIndent(),
),
)
val response = api.getOAuthProviders()
assertTrue(response.isSuccessful)
val status = response.body()!!.providers[0].status
assertEquals("1790766533634", status.expiresAt)
assertEquals("1790766000000", status.lastRefresh)
}
@Test
fun getOAuthProviders_parsesList() =
runBlocking {