Skip to content

Token storage format change in 2.1.5 breaks read of pre-2.1.5 keychain entries (and vice versa) without migration #279

Description

@vsiddharth-suki

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.swiftToken.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

  1. Build an app against okta-mobile-swift 2.1.4 (or any 2.1.0–2.1.4).
  2. Sign in via BrowserSignin or DirectAuthenticationFlow so a Credential is persisted via Credential.store(...).
  3. Bump the SPM dependency to 2.1.5, rebuild, and run on the same simulator/device (do not reinstall).
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions