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 } defer { isDeletingAccount = false }
do { do {
try await apiClient.deleteAccount() try await apiClient.deleteAccount()
session.signOut() await session.signOut()
} catch { } catch {
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
} }
@@ -1182,6 +1182,11 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
sectionHeader 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) { VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") 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 static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults private let defaults: UserDefaults
var isSignedIn: Bool var isSignedIn: Bool
@@ -43,8 +44,13 @@ final class SessionStore {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) 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.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults self.defaults = defaults
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
@@ -53,7 +59,12 @@ final class SessionStore {
if hasToken, let storedServerURL { if hasToken, let storedServerURL {
isSignedIn = true 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) userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else { } else {
// Keychain and the sandboxed UserDefaults container don't // Keychain and the sandboxed UserDefaults container don't
@@ -73,7 +84,12 @@ final class SessionStore {
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) 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) apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true isSignedIn = true
} }
@@ -99,6 +115,7 @@ final class SessionStore {
private static func makeAPIClient( private static func makeAPIClient(
serverURL: URL, serverURL: URL,
tokenStore: TokenStoring, tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore? cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) { ) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient( let live = LiveOutlineAPIClient(
@@ -106,12 +123,22 @@ final class SessionStore {
tokenStore: tokenStore tokenStore: tokenStore
) )
guard let cache else { return (live, nil) } 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) 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() try? tokenStore.clear()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey) defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false isSignedIn = false
userId = nil userId = nil
@@ -8,7 +8,7 @@ extension View {
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Log Out", role: .destructive) { Button("Log Out", role: .destructive) {
session.signOut() Task { await session.signOut() }
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {