Summary
okta-mobile-swift 2.1.5 changed the on-disk shape of the Token blob persisted to the iOS Keychain by KeychainTokenStorage, in both the encode and decode paths, with no migration shim. As a result, any blob written by 2.1.5 cannot be read by ≤2.1.4, and any blob written by ≤2.1.4 cannot be correctly read by 2.1.5 — both directions throw at first launch after the version change.
Because Keychain items survive app uninstalls on iOS, anyone whose app crosses the 2.1.4/2.1.5 boundary stays broken until the Keychain is wiped (e.g. by Erase All Content and Settings on the simulator, or — on device — by signing-identity changes that move items out of the app's access group).
Errors observed
| Direction |
Error |
Upgrade from ≤ 2.1.4 to 2.1.5 |
ClaimError.missingRequiredValue(key: "access_token") thrown from Token.init(id:issuedAt:context:json:) via Credential.find(...) |
Downgrade from 2.1.5 to ≤ 2.1.4 |
DecodingError.typeMismatch at coding path rawValue: "Expected to decode String but found a dictionary instead." |
Both errors propagate out of any call site that ends up decoding a stored Token, which in our codebase includes every Credential.find(where:) we make on launch and refresh — i.e. login, refresh, and even cleanup paths are wedged at once.
Root cause — PR #248
The change is in PR #248 "Refine JSON implementation for mutability, type safety, and performance" (commit 1e61e55c19d164e27c4774b7e2d4dcb1b46c9e9a), shipped in 2.1.5.
Sources/AuthFoundation/Token Management/Token.swift — Token.encode(to:):
- try container.encode(jsonPayload.stringValue, forKey: .rawValue) // ≤2.1.4: rawValue is a JSON-encoded String
+ try container.encode(json, forKey: .rawValue) // 2.1.5: rawValue is a nested JSON object
Sources/AuthFoundation/Token Management/Token+Initialization.swift — V2 branch of Token.init(from:):
- json = .init(try container.decode(String.self, forKey: .rawValue))
+ json = try container.decode(JSON.self, forKey: .rawValue)
Why the two errors look so different:
-
2.1.5 reading a ≤2.1.4 blob. The legacy rawValue is a JSON-encoded String. The new JSON.Value.init(from:) falls through keyedContainer and unkeyedContainer, lands in singleValueContainer(), and is captured as .primitive(.string(\"{\\\"access_token\\\":...}\")). JSON.payload’s implementation in 2.1.5 then returns [:] because value.anyValue is a String, not [String: any Sendable]:
// Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift
extension JSON {
var payload: [String: any Sendable] {
get { value.anyValue as? [String: any Sendable] ?? [:] }
...
}
}
TokenClaim.optionalValue(.accessToken, in: [:]) returns nil, no MFA acr_values escape hatch applies, and Token.init(id:issuedAt:context:json:) throws ClaimError.missingRequiredValue(key: \"access_token\").
-
≤2.1.4 reading a 2.1.5 blob. The 2.1.5 rawValue is a nested JSON object. The 2.1.3/2.1.4 V2 branch runs try container.decode(String.self, forKey: .rawValue), the keyed container rejects an object as a String, and DecodingError.typeMismatch bubbles out.
Steps to reproduce
- Build an app against
okta-mobile-swift 2.1.4 (or any 2.1.0–2.1.4).
- Sign in via
BrowserSignin or DirectAuthenticationFlow so a Credential is persisted via Credential.store(...).
- Bump the SPM dependency to 2.1.5, rebuild, and run on the same simulator/device (do not reinstall).
- On next launch, any code path that calls
Credential.find(...) throws ClaimError.missingRequiredValue(key: \"access_token\").
For the reverse direction: do the same with 2.1.5 first, then downgrade to 2.1.3/2.1.4 — Credential.find(...) throws DecodingError.typeMismatch at rawValue.
A simulator Erase All Content and Settings clears the Keychain and "resolves" the issue; a regular app reinstall does not reliably do so.
Secondary impact — cleanup gets wedged too
The default cleanup pattern (try Credential.find(where: { _ in true }).forEach { try $0.remove() }) decodes every stored token before it can remove any. A single legacy entry causes the whole call to throw, so the SDK can't self-clean — login, refresh, and logout are simultaneously broken until the user wipes Keychain manually. Any app that adopted that pattern from the docs is likely affected the same way.
Proposed fix
Add a backwards-compatible read path in Token.init(from:) so 2.1.5 can decode legacy blobs:
// V2 branch in Token+Initialization.swift
if let str = try? container.decode(String.self, forKey: .rawValue) {
json = try JSON(str) // legacy ≤2.1.4 form: stringified JSON
} else {
json = try container.decode(JSON.self, forKey: .rawValue) // 2.1.5+ form: nested object
}
That alone makes the upgrade silent. To make downgrades safe too (less critical, but it would protect customers who roll a release back), 2.1.4 / 2.1.3 would need a similar fallback — though realistically a 2.1.6 with the migration shim is enough, since users rarely intentionally downgrade.
Optionally: ship a one-time migration on first launch under the new format that re-encodes any legacy blob it successfully reads, so the legacy data path stops being exercised.
Workaround on the consumer side
Until a fix lands, consumers can iterate Credential.allIDs and decode each id in isolation (so one bad entry doesn't poison the rest), and on any decode failure fall back to SecItemDelete against the SDK's three Keychain services:
com.okta.authfoundation.keychain.storage
com.okta.authfoundation.keychain.metadata
com.okta.authfoundation.keychain.default
This lets the next logout / re-login self-heal without a Keychain wipe.
Environment
okta-mobile-swift 2.1.4 → 2.1.5 (and 2.1.5 → 2.1.3/2.1.4)
- iOS 17 / iOS 18 — reproduced on both simulator and device
- Storage backend: default
KeychainTokenStorage
- Sign-in flows used:
BrowserSignin and DirectAuthenticationFlow
Summary
okta-mobile-swift2.1.5 changed the on-disk shape of theTokenblob persisted to the iOS Keychain byKeychainTokenStorage, in both the encode and decode paths, with no migration shim. As a result, any blob written by 2.1.5 cannot be read by ≤2.1.4, and any blob written by ≤2.1.4 cannot be correctly read by 2.1.5 — both directions throw at first launch after the version change.Because Keychain items survive app uninstalls on iOS, anyone whose app crosses the 2.1.4/2.1.5 boundary stays broken until the Keychain is wiped (e.g. by
Erase All Content and Settingson the simulator, or — on device — by signing-identity changes that move items out of the app's access group).Errors observed
≤ 2.1.4to2.1.5ClaimError.missingRequiredValue(key: "access_token")thrown fromToken.init(id:issuedAt:context:json:)viaCredential.find(...)2.1.5to≤ 2.1.4DecodingError.typeMismatchat coding pathrawValue: "Expected to decode String but found a dictionary instead."Both errors propagate out of any call site that ends up decoding a stored
Token, which in our codebase includes everyCredential.find(where:)we make on launch and refresh — i.e. login, refresh, and even cleanup paths are wedged at once.Root cause — PR #248
The change is in PR #248 "Refine JSON implementation for mutability, type safety, and performance" (commit
1e61e55c19d164e27c4774b7e2d4dcb1b46c9e9a), shipped in 2.1.5.Sources/AuthFoundation/Token Management/Token.swift—Token.encode(to:):Sources/AuthFoundation/Token Management/Token+Initialization.swift— V2 branch ofToken.init(from:):Why the two errors look so different:
2.1.5 reading a ≤2.1.4 blob. The legacy
rawValueis a JSON-encoded String. The newJSON.Value.init(from:)falls throughkeyedContainerandunkeyedContainer, lands insingleValueContainer(), and is captured as.primitive(.string(\"{\\\"access_token\\\":...}\")).JSON.payload’s implementation in 2.1.5 then returns[:]becausevalue.anyValueis aString, not[String: any Sendable]:TokenClaim.optionalValue(.accessToken, in: [:])returnsnil, no MFAacr_valuesescape hatch applies, andToken.init(id:issuedAt:context:json:)throwsClaimError.missingRequiredValue(key: \"access_token\").≤2.1.4 reading a 2.1.5 blob. The 2.1.5
rawValueis a nested JSON object. The 2.1.3/2.1.4 V2 branch runstry container.decode(String.self, forKey: .rawValue), the keyed container rejects an object as aString, andDecodingError.typeMismatchbubbles out.Steps to reproduce
okta-mobile-swift2.1.4 (or any 2.1.0–2.1.4).BrowserSigninorDirectAuthenticationFlowso aCredentialis persisted viaCredential.store(...).Credential.find(...)throwsClaimError.missingRequiredValue(key: \"access_token\").For the reverse direction: do the same with 2.1.5 first, then downgrade to 2.1.3/2.1.4 —
Credential.find(...)throwsDecodingError.typeMismatchatrawValue.A simulator
Erase All Content and Settingsclears the Keychain and "resolves" the issue; a regular app reinstall does not reliably do so.Secondary impact — cleanup gets wedged too
The default cleanup pattern (
try Credential.find(where: { _ in true }).forEach { try $0.remove() }) decodes every stored token before it can remove any. A single legacy entry causes the whole call to throw, so the SDK can't self-clean — login, refresh, and logout are simultaneously broken until the user wipes Keychain manually. Any app that adopted that pattern from the docs is likely affected the same way.Proposed fix
Add a backwards-compatible read path in
Token.init(from:)so 2.1.5 can decode legacy blobs:That alone makes the upgrade silent. To make downgrades safe too (less critical, but it would protect customers who roll a release back), 2.1.4 / 2.1.3 would need a similar fallback — though realistically a 2.1.6 with the migration shim is enough, since users rarely intentionally downgrade.
Optionally: ship a one-time migration on first launch under the new format that re-encodes any legacy blob it successfully reads, so the legacy data path stops being exercised.
Workaround on the consumer side
Until a fix lands, consumers can iterate
Credential.allIDsand decode each id in isolation (so one bad entry doesn't poison the rest), and on any decode failure fall back toSecItemDeleteagainst the SDK's three Keychain services:com.okta.authfoundation.keychain.storagecom.okta.authfoundation.keychain.metadatacom.okta.authfoundation.keychain.defaultThis lets the next logout / re-login self-heal without a Keychain wipe.
Environment
okta-mobile-swift2.1.4 → 2.1.5 (and 2.1.5 → 2.1.3/2.1.4)KeychainTokenStorageBrowserSigninandDirectAuthenticationFlow