···88 let clientId: String
99 let session: URLSession
10101111+ /// Coalesces concurrent `reportTokenRejected` calls onto one refresh Task.
1212+ /// Without this, two parallel 401s would fire two refresh POSTs; the
1313+ /// second uses the already-rotated refresh_token and fails.
1414+ private var inFlightRefresh: Task<Void, Never>?
1515+1116 public init(
1217 did: DID,
1318 tokenStore: any TokenStore,
···5762 }
58635964 public func reportTokenRejected() async {
6565+ // If a refresh is already in flight, join it instead of starting a
6666+ // second one. This is critical: atproto refresh tokens rotate per
6767+ // use, so concurrent refresh attempts would race and break the chain.
6868+ if let existing = inFlightRefresh {
6969+ await existing.value
7070+ return
7171+ }
7272+ let task = Task { await performRefresh() }
7373+ inFlightRefresh = task
7474+ await task.value
7575+ inFlightRefresh = nil
7676+ }
7777+7878+ private func performRefresh() async {
6079 // Attempt a refresh; best-effort.
6180 do {
6281 guard let tokens = try await tokenStore.load(did: did),
···179179 dpopKey: dpopKey
180180 )
181181182182+ // Capture the previous DPoP key identifier (if any) so we can GC it
183183+ // after the new tokens are saved. We read it before the save so a
184184+ // crash between save and delete just leaves a harmless orphan key.
185185+ let previousKeyIdentifier = try? await tokenStore.load(did: did)?.dpopKeyIdentifier
186186+182187 let stored = StoredTokens(
183188 did: did,
184189 accessToken: tokens.accessToken,
···189194 issuer: authMeta.issuer
190195 )
191196 try await tokenStore.save(stored)
197197+198198+ if let previousKeyIdentifier, previousKeyIdentifier != keyIdentifier {
199199+ try? await tokenStore.deleteDPoPKey(identifier: previousKeyIdentifier)
200200+ }
192201 }
193202194203 private func redirectSchemeFromRedirectURI(_ uri: String) -> String {