Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319):
`SirosWallet.listWalletInstances()`, `setWalletInstanceStatus()` and
`deactivateWallet()` (revokes every instance server-side, then forgets the
local account), backed by the new `WalletInstance` type and
`BackendApiClient` methods. WIA generation now sends the logged-in passkey's
`credential_id` so the backend can link the instance to the passkey.
`AuthException.errorCode` now carries the AS's error code, so a 403
`WALLET_SUSPENDED` / `WALLET_REVOKED` login refusal is distinguishable
from a plain authentication failure.
- Answer the engine's `sign_client_auth` sign request (go-wallet-backend#317,
wallet-frontend#282) on both the legacy engine transport and WMP: sign the
RFC 9449 DPoP proof and a fresh OAuth client attestation PoP for each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,13 @@ class AuthServerClient(

if (!response.isSuccessful) {
Timber.e("AS request failed: ${response.code} — $url")
throw AuthException("AS request failed: ${response.code} — $path", code = response.code)
// Carry the AS's stable error code (e.g. WALLET_SUSPENDED /
// WALLET_REVOKED from a SID-AUTH-06 login refusal) so callers
// can tell a lifecycle refusal from a plain 401/403.
val errorCode = runCatching {
(json.parseToJsonElement(responseBody).jsonObject["error"] as? kotlinx.serialization.json.JsonPrimitive)?.content
}.getOrNull()?.takeIf { it.isNotBlank() } ?: "auth_failed"
throw AuthException("AS request failed: ${response.code} — $path", errorCode = errorCode, code = response.code)
}

Timber.d("AS response: ${response.code} — $path")
Expand Down
73 changes: 71 additions & 2 deletions sdk/auth/src/main/kotlin/org/siros/sdk/auth/BackendApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber


private val JSON_MEDIA_TYPE = "application/json".toMediaType()

/**
* Authenticated HTTP client for the wallet backend REST API.
*
Expand Down Expand Up @@ -129,7 +132,7 @@ class BackendApiClient(
}
val builder = Request.Builder()
.url("$baseUrl/user/session/refresh")
.post(body.toString().toRequestBody("application/json".toMediaType()))
.post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
addCommonHeaders(builder)
execute(builder.build())
}
Expand Down Expand Up @@ -212,13 +215,19 @@ class BackendApiClient(
* "the sub claim MUST specify client_id value of the OAuth Client";
* omitting this falls back to the instance identifier (jkt) server-side.
* @param nativeAttestation optional platform attestation evidence
* @param credentialId base64url WebAuthn credential id of the passkey this
* installation logs in with. The backend records it on the wallet
* instance so that suspending or revoking the instance also refuses
* login with that passkey (SID-AUTH-06, go-wallet-backend#319). Optional;
* older backends ignore it.
* @return WIA JWT string
*/
suspend fun generateWIA(
pop: String,
challenge: String,
clientId: String? = null,
nativeAttestation: JsonObject? = null,
credentialId: String? = null,
): String {
val body = kotlinx.serialization.json.buildJsonObject {
put("pop", kotlinx.serialization.json.JsonPrimitive(pop))
Expand All @@ -229,13 +238,65 @@ class BackendApiClient(
if (nativeAttestation != null) {
put("native_attestation", nativeAttestation)
}
if (!credentialId.isNullOrBlank()) {
put("credential_id", kotlinx.serialization.json.JsonPrimitive(credentialId))
}
}
val result = post("/wallet-provider/wia/generate", body)
return result["wallet_instance_attestation"]?.let {
(it as? kotlinx.serialization.json.JsonPrimitive)?.content
} ?: throw BackendApiException(0, "Missing wallet_instance_attestation in response", "")
}

// ---- Wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319) ----

/** GET /user/session/instances — this user's wallet instances in the current tenant. */
suspend fun listWalletInstances(): List<WalletInstance> {
val result = get("/user/session/instances")
// The backend always sends the array (empty when the user has no
// instances); its absence is a malformed response, not "no instances".
val arr = result["instances"] as? kotlinx.serialization.json.JsonArray
?: throw BackendApiException(0, "Missing instances in response", "")
return arr.map { json.decodeFromJsonElement(WalletInstance.serializer(), it) }
}

/**
* PUT /user/session/instances/{id}/status — suspend, reactivate or revoke
* one of this user's instances. Throws [BackendApiException] with code 404
* for an instance that is not the caller's and 409 for an invalid
* transition (e.g. reactivating a revoked instance).
*/
suspend fun setWalletInstanceStatus(instanceId: String, status: String, reason: String? = null): WalletInstance {
val body = kotlinx.serialization.json.buildJsonObject {
put("status", kotlinx.serialization.json.JsonPrimitive(status))
if (!reason.isNullOrBlank()) put("reason", kotlinx.serialization.json.JsonPrimitive(reason))
}
val result = put("/user/session/instances/$instanceId/status", body)
// Today the backend answers {id, status}; decode the whole object when
// it sends more, so callers see every field it returns.
return runCatching { json.decodeFromJsonElement(WalletInstance.serializer(), result) }.getOrElse {
WalletInstance(
id = (result["id"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: instanceId,
status = (result["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content
?: throw BackendApiException(0, "Missing status in response", ""),
)
}
}

/**
* POST /user/session/instances/revoke-all — deactivate the wallet: every
* instance revoked, wallet data erased server-side, new enrollment required.
* @return how many instances were revoked by this call
*/
suspend fun revokeAllWalletInstances(reason: String? = null): Int {
val body = kotlinx.serialization.json.buildJsonObject {
if (!reason.isNullOrBlank()) put("reason", kotlinx.serialization.json.JsonPrimitive(reason))
}
val result = post("/user/session/instances/revoke-all", body)
return (result["revoked"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull()
?: throw BackendApiException(0, "Missing revoked count in response", "")
}

/**
* POST /wallet-provider/fido2-attestation/register — register a FIDO2/CTAP2
* hardware-key attestation once, at key-creation time, so the backend can
Expand Down Expand Up @@ -290,7 +351,15 @@ class BackendApiClient(
private suspend fun post(path: String, body: JsonObject): JsonObject = withContext(Dispatchers.IO) {
val builder = Request.Builder()
.url("$baseUrl$path")
.post(body.toString().toRequestBody("application/json".toMediaType()))
.post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
addCommonHeaders(builder)
execute(builder.build())
}

private suspend fun put(path: String, body: JsonObject): JsonObject = withContext(Dispatchers.IO) {
val builder = Request.Builder()
.url("$baseUrl$path")
.put(body.toString().toRequestBody(JSON_MEDIA_TYPE))
addCommonHeaders(builder)
execute(builder.build())
}
Expand Down
38 changes: 38 additions & 0 deletions sdk/auth/src/main/kotlin/org/siros/sdk/auth/WalletInstance.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.siros.sdk.auth

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* One wallet installation as the backend sees it (go-wallet-backend
* `domain.WalletInstance`): registered when the installation first obtains a
* Wallet Instance Attestation and identified by the JWK thumbprint of its
* instance key. Lifecycle (SID-AUTH-06): `active` → `suspended` is reversible
* and only blocks; `revoked` is terminal; revoking the last non-revoked
* instance deactivates the wallet and erases its data server-side.
*/
@Serializable
data class WalletInstance(
val id: String,
@SerialName("tenant_id") val tenantId: String = "",
@SerialName("user_id") val userId: String? = null,
val status: String,
@SerialName("wscd_type") val wscdType: String = "",
/**
* base64url WebAuthn credential id of the passkey this instance logs in
* with, when the client reported it at WIA generation. Lets an app match
* an instance to a passkey; absent for instances attested by older SDKs.
*/
@SerialName("credential_id") val credentialId: String? = null,
@SerialName("attestation_source") val attestationSource: String = "",
@SerialName("last_attested_at") val lastAttestedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("status_reason") val statusReason: String? = null,
) {
companion object {
const val STATUS_ACTIVE = "active"
const val STATUS_SUSPENDED = "suspended"
const val STATUS_REVOKED = "revoked"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.siros.sdk.credentials.AuthException
Expand Down Expand Up @@ -190,4 +191,33 @@ class AuthServerClientTest {
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}"""))
client.loginBegin()
}

/**
* SID-AUTH-06 (go-wallet-backend#319): a suspended or revoked wallet
* instance is a 403 with a stable code, not a retryable auth failure -
* the code must survive into the exception so the app can explain it.
*/
@Test
fun `login refusal carries the AS error code`(): Unit = runBlocking {
server.enqueue(MockResponse().setResponseCode(403).setBody("""{"error":"WALLET_REVOKED"}"""))
try {
client.loginFinish(challengeId = "c1", credential = buildJsonObject { })
fail("expected AuthException")
} catch (e: AuthException) {
assertEquals(403, e.code)
assertEquals("WALLET_REVOKED", e.errorCode)
}
}

@Test
fun `non-JSON failure body falls back to auth_failed`(): Unit = runBlocking {
server.enqueue(MockResponse().setResponseCode(502).setBody("bad gateway"))
try {
client.loginBegin()
fail("expected AuthException")
} catch (e: AuthException) {
assertEquals(502, e.code)
assertEquals("auth_failed", e.errorCode)
}
}
}
111 changes: 111 additions & 0 deletions sdk/auth/src/test/kotlin/org/siros/sdk/auth/BackendApiClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import java.util.Base64
Expand Down Expand Up @@ -339,6 +340,116 @@ class BackendApiClientTest {
return AccessToken("$header.$body.$signature")
}

// ---- wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319) ----

@Test
fun generate_wia_sends_credential_id_when_given_and_omits_it_otherwise() = runBlocking {
server.enqueue(MockResponse().setBody("""{"wallet_instance_attestation":"wia.jwt"}"""))
server.enqueue(MockResponse().setBody("""{"wallet_instance_attestation":"wia.jwt"}"""))
val client = newClient()
client.setAppToken("t")

client.generateWIA(pop = "pop.jwt", challenge = "c-1", clientId = "siros://cb", credentialId = "pk-1")
val withId = server.takeRequest().body.readUtf8()
assertTrue(withId, withId.contains("\"credential_id\":\"pk-1\""))

client.generateWIA(pop = "pop.jwt", challenge = "c-2")
val without = server.takeRequest().body.readUtf8()
assertTrue(without, !without.contains("credential_id"))
}

@Test
fun list_wallet_instances_decodes_backend_shape() = runBlocking {
server.enqueue(
MockResponse().setBody(
"""{"instances":[{"id":"jkt-1","tenant_id":"default","user_id":"u1","status":"suspended",
"wscd_type":"native_android","credential_id":"pk-1","attestation_source":"play_integrity",
"last_attested_at":"2026-09-08T10:00:00Z","status_reason":"lost phone","unknown_member":1}]}"""
)
)
val client = newClient()
client.setAppToken("t")

val instances = client.listWalletInstances()

assertEquals("/user/session/instances", server.takeRequest().path)
assertEquals(1, instances.size)
assertEquals("jkt-1", instances[0].id)
assertEquals(WalletInstance.STATUS_SUSPENDED, instances[0].status)
assertEquals("pk-1", instances[0].credentialId)
assertEquals("lost phone", instances[0].statusReason)
}

@Test
fun list_wallet_instances_fails_on_malformed_response() = runBlocking {
server.enqueue(MockResponse().setBody("""{"unexpected":true}"""))
val client = newClient()
client.setAppToken("t")
try {
client.listWalletInstances()
fail("a response without the instances array must not read as 'no instances'")
} catch (e: BackendApiException) {
assertTrue(e.message!!.contains("instances"))
}
}

@Test
fun set_wallet_instance_status_decodes_full_object_when_returned() = runBlocking {
server.enqueue(MockResponse().setBody("""{"id":"jkt-1","status":"revoked","credential_id":"pk-1","status_reason":"stolen"}"""))
val client = newClient()
client.setAppToken("t")

val result = client.setWalletInstanceStatus("jkt-1", WalletInstance.STATUS_REVOKED, "stolen")

assertEquals("revoked", result.status)
assertEquals("pk-1", result.credentialId)
assertEquals("stolen", result.statusReason)
}

@Test
fun set_wallet_instance_status_puts_status_and_reason() = runBlocking {
server.enqueue(MockResponse().setBody("""{"id":"jkt-1","status":"suspended"}"""))
val client = newClient()
client.setAppToken("t")

val result = client.setWalletInstanceStatus("jkt-1", WalletInstance.STATUS_SUSPENDED, reason = "lost phone")

val request = server.takeRequest()
assertEquals("PUT", request.method)
assertEquals("/user/session/instances/jkt-1/status", request.path)
val body = request.body.readUtf8()
assertTrue(body, body.contains("\"status\":\"suspended\"") && body.contains("\"reason\":\"lost phone\""))
assertEquals("suspended", result.status)
}

@Test
fun revoke_all_wallet_instances_fails_on_malformed_response() = runBlocking {
server.enqueue(MockResponse().setBody("""{"ok":true}"""))
val client = newClient()
client.setAppToken("t")
try {
client.revokeAllWalletInstances()
fail("a reply without the revoked count must not read as zero revoked")
} catch (e: BackendApiException) {
assertTrue(e.message!!.contains("revoked"))
}
}

@Test
fun revoke_all_wallet_instances_posts_and_returns_count() = runBlocking {
server.enqueue(MockResponse().setBody("""{"revoked":2}"""))
val client = newClient()
client.setAppToken("t")

val revoked = client.revokeAllWalletInstances("device stolen")

val request = server.takeRequest()
assertEquals("POST", request.method)
assertEquals("/user/session/instances/revoke-all", request.path)
assertTrue(request.body.readUtf8().contains("device stolen"))
assertEquals(2, revoked)
}

private fun newClient(): BackendApiClient {
val baseUrl = server.url("/").toString().trimEnd('/')
return BackendApiClient(baseUrl = baseUrl, tenantId = "default")
Expand Down
Loading
Loading