Skip to content

Commit 2f63261

Browse files
committed
fix(WAL-995): clean up interrupted issuance sessions
1 parent 4bdaea9 commit 2f63261

4 files changed

Lines changed: 205 additions & 4 deletions

File tree

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/src/commonMain/kotlin/id/walt/wallet2/mobile/MobileWallet.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,10 +585,13 @@ public class MobileWallet internal constructor(
585585
/**
586586
* Deletes local wallet material owned by this mobile wallet instance.
587587
*
588-
* The active key, credential, and DID stores receive store-level remove calls. The wallet then closes
589-
* and deletes the encrypted local database and deletes the configured database key.
588+
* Active issuance continuations are invalidated before the key, credential, and DID stores receive
589+
* store-level remove calls. The wallet then closes and deletes the encrypted local database and deletes
590+
* the configured database key.
590591
*/
591592
public suspend fun deleteWallet() {
593+
issuanceSessions.clearSessions()
594+
legacyIssuanceMutex.withLock { legacyIssuanceSessions.clear() }
592595
keyStore.listKeys().toList().forEach { key ->
593596
keyStore.removeKey(key.keyId)
594597
}

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/src/commonTest/kotlin/id/walt/wallet2/mobile/MobileWalletTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import id.walt.wallet2.data.WalletDidStore
1212
import id.walt.wallet2.data.WalletKeyInfo
1313
import id.walt.wallet2.data.WalletKeyStore
1414
import id.walt.wallet2.data.WalletSessionEvent
15+
import id.walt.wallet2.handlers.WalletIssuanceSessionRecord
16+
import id.walt.wallet2.handlers.WalletIssuanceSessionRecordKind
17+
import id.walt.wallet2.handlers.WalletIssuanceSessionStore
1518
import id.walt.wallet2.persistence.encryption.DatabaseEncryptionKey
1619
import id.walt.wallet2.persistence.encryption.DatabaseEncryptionKeyProvider
1720
import id.waltid.openid4vp.wallet.WalletPresentFunctionality2.WalletPresentResult
@@ -147,11 +150,28 @@ class MobileWalletTest {
147150
val keyStore = PreloadedKeyStore(WalletKeyInfo(keyId = "custom-key", keyType = "secp256r1"))
148151
val didStore = PreloadedDidStore(WalletDidEntry(did = "did:key:custom", document = JsonObject(emptyMap())))
149152
val credentialStore = RecordingCredentialStore()
153+
val issuanceSessionStore = RecordingIssuanceSessionStore(
154+
WalletIssuanceSessionRecord(
155+
id = "active:session-1",
156+
sessionId = "session-1",
157+
kind = WalletIssuanceSessionRecordKind.ACTIVE_SESSION,
158+
payload = "<encrypted>",
159+
updatedAtEpochMilliseconds = 1L,
160+
),
161+
WalletIssuanceSessionRecord(
162+
id = "deferred:credential-1",
163+
sessionId = "session-1",
164+
kind = WalletIssuanceSessionRecordKind.DEFERRED_CREDENTIAL,
165+
payload = "<encrypted>",
166+
updatedAtEpochMilliseconds = 2L,
167+
),
168+
)
150169
val wallet = MobileWallet(
151170
walletId = "custom-wallet",
152171
keyStore = keyStore,
153172
didStore = didStore,
154173
credentialStore = credentialStore,
174+
issuanceSessionStore = issuanceSessionStore,
155175
keyGenerator = { error("deleteWallet should not generate a key") },
156176
)
157177

@@ -160,6 +180,7 @@ class MobileWalletTest {
160180
assertEquals(listOf("custom-key"), keyStore.removedKeyIds)
161181
assertEquals(listOf("did:key:custom"), didStore.removedDids)
162182
assertEquals(emptyList(), credentialStore.removedCredentialIds)
183+
assertTrue(issuanceSessionStore.records.isEmpty())
163184
}
164185

165186
@Test
@@ -452,4 +473,17 @@ class MobileWalletTest {
452473
return true
453474
}
454475
}
476+
477+
private class RecordingIssuanceSessionStore(
478+
vararg records: WalletIssuanceSessionRecord,
479+
) : WalletIssuanceSessionStore {
480+
val records = records.associateByTo(linkedMapOf()) { it.id }
481+
482+
override suspend fun get(id: String): WalletIssuanceSessionRecord? = records[id]
483+
override suspend fun list(): List<WalletIssuanceSessionRecord> = records.values.toList()
484+
override suspend fun put(record: WalletIssuanceSessionRecord) {
485+
records[record.id] = record
486+
}
487+
override suspend fun remove(id: String): Boolean = records.remove(id) != null
488+
}
455489
}

waltid-libraries/protocols/waltid-openid4vc-wallet/src/commonMain/kotlin/id/walt/wallet2/handlers/WalletIssuanceSessionService.kt

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,19 @@ class WalletIssuanceSessionService(
418418
return if (removed) WalletIssuanceOutcome.Cancelled(sessionId) else invalidSession(sessionId)
419419
}
420420

421+
/** Invalidates and removes every active and deferred issuance continuation. */
422+
suspend fun clearSessions() {
423+
withContext(NonCancellable) {
424+
mutex.withLock {
425+
sessions.clear()
426+
deferred.clear()
427+
}
428+
sessionStore?.let { store ->
429+
store.list().forEach { store.remove(it.id) }
430+
}
431+
}
432+
}
433+
421434
/** Polls one deferred result while preserving its access material inside the engine. */
422435
suspend fun resumeDeferred(deferredCredentialId: String): WalletIssuanceOutcome {
423436
val record = try {
@@ -1136,22 +1149,28 @@ class WalletIssuanceSessionService(
11361149
try {
11371150
persistActive(active)
11381151
} catch (error: CancellationException) {
1152+
invalidateActiveSessionBestEffort(sessionId)
11391153
throw error
11401154
} catch (error: Exception) {
1141-
mutex.withLock { active.state = expected }
1155+
invalidateActiveSessionBestEffort(sessionId)
11421156
throw IssuanceStageException(WalletIssuanceErrorCode.STORAGE, error)
11431157
}
11441158
return active
11451159
}
11461160

11471161
private suspend fun loadActive(sessionId: String): ActiveSession? {
11481162
mutex.withLock { sessions[sessionId] }?.let { return it }
1149-
val record = sessionStore?.get(activeRecordId(sessionId)) ?: return null
1163+
val store = sessionStore ?: return null
1164+
val record = store.get(activeRecordId(sessionId)) ?: return null
11501165
require(record.kind == WalletIssuanceSessionRecordKind.ACTIVE_SESSION && record.sessionId == sessionId) {
11511166
"Stored issuance session binding is invalid"
11521167
}
11531168
val persisted = json.decodeFromString<PersistedActiveSession>(record.payload)
11541169
require(persisted.public.id == sessionId) { "Stored issuance session identifier is invalid" }
1170+
if (persisted.state == SessionState.PROCESSING) {
1171+
invalidateActiveSessionBestEffort(sessionId)
1172+
return null
1173+
}
11551174

11561175
val offer = json.decodeFromString<CredentialOffer>(persisted.offer)
11571176
val issuerMetadata = json.decodeFromString<CredentialIssuerMetadata>(persisted.issuerMetadata)
@@ -1385,6 +1404,17 @@ class WalletIssuanceSessionService(
13851404
removePersistedActive(sessionId)
13861405
}
13871406

1407+
private suspend fun invalidateActiveSessionBestEffort(sessionId: String) {
1408+
withContext(NonCancellable) {
1409+
mutex.withLock { sessions.remove(sessionId) }
1410+
try {
1411+
removePersistedActive(sessionId)
1412+
} catch (_: Exception) {
1413+
// A retained PROCESSING marker is rejected when the session is next loaded.
1414+
}
1415+
}
1416+
}
1417+
13881418
private suspend fun restoreSession(active: ActiveSession, state: SessionState) {
13891419
var restored = false
13901420
mutex.withLock {

waltid-libraries/protocols/waltid-openid4vc-wallet/src/jvmTest/kotlin/id/walt/wallet2/handlers/WalletIssuanceSessionServiceTest.kt

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,124 @@ class WalletIssuanceSessionServiceTest {
325325
})
326326
}
327327

328+
@Test
329+
fun cancellationWhilePersistingTransitionInvalidatesSession() = runTest {
330+
val key = JWKKey.generate(KeyType.secp256r1)
331+
val records = BlockingTransitionSessionStore()
332+
val client = client { request ->
333+
when (request.url.toString()) {
334+
ISSUER_METADATA -> jsonResponse(issuerMetadata(proofRequired = false))
335+
AS_METADATA -> jsonResponse(authorizationServerMetadata(authorizationCode = false))
336+
else -> respondError(HttpStatusCode.NotFound)
337+
}
338+
}
339+
val service = WalletIssuanceSessionService(
340+
wallet = Wallet("transition-cancellation", staticKey = key),
341+
sessionStore = records,
342+
httpClient = client,
343+
)
344+
val session = service.start(preAuthorizedRequest())
345+
346+
val continuation = async { service.continuePreAuthorized(session.id) }
347+
records.processingWriteStarted.await()
348+
continuation.cancel()
349+
350+
assertFailsWith<CancellationException> { continuation.await() }
351+
assertTrue(records.records.isEmpty())
352+
assertEquals(
353+
WalletIssuanceErrorCode.INVALID_SESSION,
354+
assertIs<WalletIssuanceOutcome.Failed>(service.continuePreAuthorized(session.id)).error.code,
355+
)
356+
}
357+
358+
@Test
359+
fun processingSessionIsDiscardedAfterServiceRecreation() = runTest {
360+
val key = JWKKey.generate(KeyType.secp256r1)
361+
val records = RecordingSessionStore()
362+
val client = client { request ->
363+
when (request.url.toString()) {
364+
ISSUER_METADATA -> jsonResponse(issuerMetadata(proofRequired = false))
365+
AS_METADATA -> jsonResponse(authorizationServerMetadata(authorizationCode = false))
366+
else -> respondError(HttpStatusCode.NotFound)
367+
}
368+
}
369+
val wallet = Wallet("interrupted-transition", staticKey = key)
370+
val session = WalletIssuanceSessionService(wallet, sessionStore = records, httpClient = client)
371+
.start(preAuthorizedRequest())
372+
val active = records.records.values.single()
373+
val processingPayload = active.payload.replace("\"state\":\"READY\"", "\"state\":\"PROCESSING\"")
374+
assertTrue(processingPayload != active.payload)
375+
records.records[active.id] = active.copy(payload = processingPayload)
376+
377+
val restored = WalletIssuanceSessionService(wallet, sessionStore = records, httpClient = client)
378+
val outcome = restored.continuePreAuthorized(session.id)
379+
380+
assertEquals(
381+
WalletIssuanceErrorCode.INVALID_SESSION,
382+
assertIs<WalletIssuanceOutcome.Failed>(outcome).error.code,
383+
)
384+
assertTrue(records.records.isEmpty())
385+
}
386+
387+
@Test
388+
fun clearSessionsRemovesActiveAndDeferredContinuations() = runTest {
389+
val key = JWKKey.generate(KeyType.secp256r1)
390+
val records = RecordingSessionStore()
391+
var authorizationServerMetadataCalls = 0
392+
val client = client { request ->
393+
when (request.url.toString()) {
394+
ISSUER_METADATA -> jsonResponse(issuerMetadata(proofRequired = false))
395+
AS_METADATA -> jsonResponse(
396+
authorizationServerMetadata(authorizationCode = authorizationServerMetadataCalls++ == 0)
397+
)
398+
TOKEN_ENDPOINT -> jsonResponse("""{"access_token":"access","token_type":"Bearer"}""")
399+
CREDENTIAL_ENDPOINT -> jsonResponse(
400+
"""{"transaction_id":"transaction-1","interval":5}""",
401+
HttpStatusCode.Accepted,
402+
)
403+
else -> respondError(HttpStatusCode.NotFound)
404+
}
405+
}
406+
val service = WalletIssuanceSessionService(
407+
wallet = Wallet("clear-sessions", staticKey = key),
408+
sessionStore = records,
409+
httpClient = client,
410+
)
411+
val authorization = service.start(authRequest())
412+
val preAuthorized = service.start(preAuthorizedRequest())
413+
val deferred = assertIs<WalletIssuanceOutcome.Deferred>(
414+
service.continuePreAuthorized(preAuthorized.id)
415+
)
416+
assertEquals(
417+
setOf(
418+
WalletIssuanceSessionRecordKind.ACTIVE_SESSION,
419+
WalletIssuanceSessionRecordKind.DEFERRED_CREDENTIAL,
420+
),
421+
records.records.values.map { it.kind }.toSet(),
422+
)
423+
424+
service.clearSessions()
425+
426+
assertTrue(records.records.isEmpty())
427+
assertEquals(
428+
WalletIssuanceErrorCode.INVALID_SESSION,
429+
assertIs<WalletIssuanceOutcome.Failed>(
430+
service.continueAuthorization(
431+
WalletIssuanceAuthorizationCallback(
432+
authorization.id,
433+
callback(authorization, "after-clear"),
434+
)
435+
)
436+
).error.code,
437+
)
438+
assertEquals(
439+
WalletIssuanceErrorCode.INVALID_SESSION,
440+
assertIs<WalletIssuanceOutcome.Failed>(
441+
service.resumeDeferred(deferred.credentials.single().id)
442+
).error.code,
443+
)
444+
}
445+
328446
@Test
329447
fun deferredCredentialCanBeResumedAndStored() = runTest {
330448
val key = JWKKey.generate(KeyType.secp256r1)
@@ -1004,4 +1122,20 @@ class WalletIssuanceSessionServiceTest {
10041122
}
10051123
override suspend fun remove(id: String): Boolean = records.remove(id) != null
10061124
}
1125+
1126+
private class BlockingTransitionSessionStore : WalletIssuanceSessionStore {
1127+
val records = linkedMapOf<String, WalletIssuanceSessionRecord>()
1128+
val processingWriteStarted = CompletableDeferred<Unit>()
1129+
1130+
override suspend fun get(id: String): WalletIssuanceSessionRecord? = records[id]
1131+
override suspend fun list(): List<WalletIssuanceSessionRecord> = records.values.toList()
1132+
override suspend fun put(record: WalletIssuanceSessionRecord) {
1133+
if ("\"state\":\"PROCESSING\"" in record.payload) {
1134+
processingWriteStarted.complete(Unit)
1135+
kotlinx.coroutines.awaitCancellation()
1136+
}
1137+
records[record.id] = record
1138+
}
1139+
override suspend fun remove(id: String): Boolean = records.remove(id) != null
1140+
}
10071141
}

0 commit comments

Comments
 (0)