Skip to content

Commit d945bf0

Browse files
committed
fix: preserve Cloud sign-in across native app restarts
1 parent d1c6ce0 commit d945bf0

3 files changed

Lines changed: 143 additions & 13 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from 'node:assert/strict'
2+
import { randomUUID } from 'node:crypto'
3+
import { it } from 'node:test'
4+
import { registerPlugin } from '@capacitor/core'
5+
import { loadMobileModule } from '../../tooling/load-mobile-module.ts'
6+
7+
const account = {
8+
base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z',
9+
user: { name: 'Test', email: 'test@example.test' },
10+
device: { id: 'test-device', name: 'Test phone', platform: 'android' }
11+
}
12+
const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account })
13+
14+
async function coldLaunch(saved: Map<string, string>) {
15+
// Exercise the real Capacitor lazy proxy: concurrent first calls can
16+
// instantiate separate implementations, each with its own key prefix.
17+
const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, {
18+
web: async () => {
19+
await Promise.resolve()
20+
return new class {
21+
prefix = 'capacitor-storage_'
22+
async setKeyPrefix(prefix: string) { this.prefix = prefix }
23+
async setSynchronize(_value: boolean) {}
24+
async setDefaultKeychainAccess(_value: unknown) {}
25+
async getItem(key: string) { return saved.get(this.prefix + key) ?? null }
26+
async setItem(key: string, value: string) { saved.set(this.prefix + key, value) }
27+
async removeItem(key: string) { saved.delete(this.prefix + key) }
28+
}()
29+
}
30+
})
31+
const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', {
32+
'@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} },
33+
'@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } },
34+
'@aparajita/capacitor-secure-storage': {
35+
SecureStorage: secureStorage,
36+
KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' }
37+
},
38+
'./cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') }
39+
})
40+
await api.configureMobileCloudAuth('test-version')
41+
return api
42+
}
43+
44+
for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) {
45+
it(`loads a saved account from ${prefix} on every cold launch`, async () => {
46+
const saved = new Map([[prefix + 'credential', credential]])
47+
for (let launch = 0; launch < 2; launch++) {
48+
const api = await coldLaunch(saved)
49+
const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()])
50+
assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }])
51+
assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential'])
52+
}
53+
})
54+
}
55+
56+
it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => {
57+
const saved = new Map([
58+
['zennotes.cloud.credential', credential],
59+
['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })]
60+
])
61+
const api = await coldLaunch(saved)
62+
assert.equal((await api.authenticatedCredential()).token, 'test-only')
63+
await api.logoutMobileCloudAccount()
64+
assert.equal(saved.size, 0)
65+
assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
66+
})
67+
68+
it('rejects invalid recovered credentials through the shared auth validator', async () => {
69+
const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account })
70+
const saved = new Map([['capacitor-storage_credential', invalid]])
71+
const api = await coldLaunch(saved)
72+
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null })
73+
assert.equal(saved.size, 0)
74+
})
75+
76+
it('recovers pending sign-in state across cold launches', async () => {
77+
const pending = JSON.stringify({
78+
base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z'
79+
})
80+
const saved = new Map([['capacitor-storage_pending-auth', pending]])
81+
for (let launch = 0; launch < 2; launch++) {
82+
const api = await coldLaunch(saved)
83+
assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null })
84+
assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]])
85+
}
86+
})

src/bridge/mobile-cloud-auth.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,33 +34,76 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>()
3434
let authFlow: CloudAuthFlow | null = null
3535
let callbackQueue = Promise.resolve()
3636

37-
const secureStorageReady = Capacitor.isNativePlatform()
38-
? Promise.all([
39-
SecureStorage.setKeyPrefix('zennotes.cloud.'),
40-
SecureStorage.setSynchronize(false),
41-
SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
42-
]).then(() => undefined)
43-
: Promise.resolve()
37+
// Lazy and retryable so a transient native storage failure does not poison
38+
// every later account read for the session.
39+
let secureStorageSetup: Promise<void> | null = null
40+
function secureStorageReady(): Promise<void> {
41+
if (!Capacitor.isNativePlatform()) return Promise.resolve()
42+
if (!secureStorageSetup) {
43+
secureStorageSetup = configureSecureStorage()
44+
secureStorageSetup.catch(() => {
45+
secureStorageSetup = null
46+
})
47+
}
48+
return secureStorageSetup
49+
}
50+
51+
async function configureSecureStorage(): Promise<void> {
52+
// The Capacitor proxy loads its implementation lazily. Parallel first
53+
// calls can initialize separate instances and lose the configured prefix.
54+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
55+
await SecureStorage.setSynchronize(false)
56+
await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly)
57+
await migrateAuthStoragePrefix()
58+
}
59+
60+
async function migrateAuthStoragePrefix(): Promise<void> {
61+
const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY]
62+
const canonical = new Map<string, string | null>()
63+
for (const key of keys) canonical.set(key, await SecureStorage.getItem(key))
64+
65+
// Affected builds could persist auth under the plugin's default prefix.
66+
// All storage callers await setup, so none can see this temporary prefix.
67+
// CloudAuthFlow still validates every recovered record before using it.
68+
try {
69+
await SecureStorage.setKeyPrefix('capacitor-storage_')
70+
const legacy = new Map<string, string>()
71+
for (const key of keys) {
72+
const value = await SecureStorage.getItem(key)
73+
if (value !== null) legacy.set(key, value)
74+
}
75+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
76+
for (const [key, value] of legacy) {
77+
if (canonical.get(key) === null) await SecureStorage.setItem(key, value)
78+
}
79+
// Remove superseded records as well, so logout cannot resurrect an older
80+
// credential on the next launch. Copying must finish before removal.
81+
await SecureStorage.setKeyPrefix('capacitor-storage_')
82+
for (const key of legacy.keys()) await SecureStorage.removeItem(key)
83+
} finally {
84+
await SecureStorage.setKeyPrefix('zennotes.cloud.')
85+
}
86+
}
4487

4588
const storage: CloudAuthStorage = {
4689
async loadPending(): Promise<unknown> {
4790
if (!Capacitor.isNativePlatform()) return null
48-
await secureStorageReady
91+
await secureStorageReady()
4992
return parseStoredJson(await SecureStorage.getItem(PENDING_AUTH_KEY))
5093
},
5194
async savePending(pending: CloudAuthPending): Promise<void> {
5295
assertNativeCloudAuth()
53-
await secureStorageReady
96+
await secureStorageReady()
5497
await SecureStorage.setItem(PENDING_AUTH_KEY, JSON.stringify(pending))
5598
},
5699
async deletePending(): Promise<void> {
57100
if (!Capacitor.isNativePlatform()) return
58-
await secureStorageReady
101+
await secureStorageReady()
59102
await SecureStorage.removeItem(PENDING_AUTH_KEY)
60103
},
61104
async loadCredential(): Promise<unknown> {
62105
if (!Capacitor.isNativePlatform()) return null
63-
await secureStorageReady
106+
await secureStorageReady()
64107
const credential = migrateLegacyCloudCredential(
65108
parseStoredJson(await SecureStorage.getItem(CREDENTIAL_KEY))
66109
)
@@ -71,13 +114,13 @@ const storage: CloudAuthStorage = {
71114
},
72115
async saveCredential(credential: CloudAuthCredential): Promise<void> {
73116
assertNativeCloudAuth()
74-
await secureStorageReady
117+
await secureStorageReady()
75118
const canonicalCredential = migrateLegacyCloudCredential(credential).value
76119
await SecureStorage.setItem(CREDENTIAL_KEY, JSON.stringify(canonicalCredential))
77120
},
78121
async deleteCredential(): Promise<void> {
79122
if (!Capacitor.isNativePlatform()) return
80-
await secureStorageReady
123+
await secureStorageReady()
81124
await SecureStorage.removeItem(CREDENTIAL_KEY)
82125
}
83126
}

tooling/load-mobile-module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export async function loadMobileModule(
2323
platform: 'node',
2424
format: 'cjs',
2525
write: false,
26+
define: { 'import.meta.env': '{}' },
2627
plugins: [{
2728
name: 'mobile-test-boundaries',
2829
setup(plugin: any) {

0 commit comments

Comments
 (0)