feat(app): clear the cache encryption key on sign-out, note it in Settings

SessionStore.signOut() now clears the offline cache's Keychain-stored
encryption key alongside the API token, and wipes the cache/pending-
write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut())
before doing so - so a previous account's cached content isn't sitting
there readable (even in principle, if the on-disk rows survive) by
whoever signs in next on the same machine. signOut() is async now to
do this properly instead of firing a detached Task; both call sites
(the logout confirmation dialog, delete-account) updated.

Settings -> Offline & Sync now states plainly that the local cache is
encrypted at rest and cleared on log out - not compiler-verified
(Outpost app target has no CLI build path), worth a look in Xcode.
This commit is contained in:
2026-08-21 01:15:26 +01:00
parent 20baab78c0
commit f624ce6c9f
3 changed files with 39 additions and 7 deletions
+6 -1
View File
@@ -618,7 +618,7 @@ struct SettingsView: View {
defer { isDeletingAccount = false }
do {
try await apiClient.deleteAccount()
session.signOut()
await session.signOut()
} catch {
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
}
@@ -1182,6 +1182,11 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
+32 -5
View File
@@ -15,6 +15,7 @@ final class SessionStore {
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults
var isSignedIn: Bool
@@ -43,8 +44,13 @@ final class SessionStore {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
}
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
init(
tokenStore: TokenStoring = KeychainTokenStore(),
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
defaults: UserDefaults = .standard
) {
self.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
@@ -53,7 +59,12 @@ final class SessionStore {
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: storedServerURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else {
// Keychain and the sandboxed UserDefaults container don't
@@ -73,7 +84,12 @@ final class SessionStore {
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: serverURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
@@ -99,6 +115,7 @@ final class SessionStore {
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
@@ -106,12 +123,22 @@ final class SessionStore {
tokenStore: tokenStore
)
guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache)
let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
return (caching, caching)
}
func signOut() {
/// Clears everything scoped to this sign-in: the API token, the offline
/// cache's encryption key, and the cache/pending-write storage itself
/// (in that order wiping storage before the key would leave it
/// readable a moment longer than necessary, and wiping the key without
/// the storage would leave permanently-undecryptable rows sitting
/// around instead of actually freeing anything). Whoever signs in next
/// on this machine gets a clean slate, not a previous account's
/// leftover cached content.
func signOut() async {
try? tokenStore.clear()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
@@ -8,7 +8,7 @@ extension View {
titleVisibility: .visible
) {
Button("Log Out", role: .destructive) {
session.signOut()
Task { await session.signOut() }
}
Button("Cancel", role: .cancel) {}
} message: {