diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 41686883f71..bac5497496b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -145,6 +145,7 @@ import { import { nativeRefreshUrl, type NativeTokenSet, + parseStoredTokenSet, parseTokenResponse, resolveLoginStrategy, tokenNeedsRefresh @@ -6276,11 +6277,15 @@ function _loadNativeTokens(baseUrl: string): NativeTokenSet | null { return null } - const tokens = parseTokenResponse(JSON.parse(plaintext)) + const tokens = parseStoredTokenSet(JSON.parse(plaintext)) _nativeTokens.set(baseUrl, tokens) return tokens - } catch { + } catch (error) { + rememberLog( + `[native-oauth] failed to load stored tokens for ${baseUrl}: ${(error as Error).message}` + ) + return null } } diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts index 58d863c4b1a..38abae643be 100644 --- a/apps/desktop/electron/native-oauth.test.ts +++ b/apps/desktop/electron/native-oauth.test.ts @@ -20,6 +20,7 @@ import { nativeRefreshUrl, nativeTokenUrl, parseLoopbackCallback, + parseStoredTokenSet, parseTokenResponse, resolveLoginStrategy, statusSupportsNativeFlow, @@ -176,6 +177,29 @@ test('parseTokenResponse tolerates an absent refresh token / expiry', () => { assert.equal(t.expiresAt, 0) }) +test('parseStoredTokenSet maps the encrypted on-disk camelCase shape', () => { + const t = parseStoredTokenSet({ + accessToken: 'AT-stored', + refreshToken: 'RT-stored', + expiresAt: 1893456000, + provider: 'self-hosted', + userId: 'u-stored' + }) + + assert.equal(t.accessToken, 'AT-stored') + assert.equal(t.refreshToken, 'RT-stored') + assert.equal(t.expiresAt, 1893456000) + assert.equal(t.provider, 'self-hosted') + assert.equal(t.userId, 'u-stored') +}) + +test('parseStoredTokenSet rejects a non-normalized server response', () => { + assert.throws( + () => parseStoredTokenSet({ access_token: 'AT-server' }), + /missing accessToken/i + ) +}) + // --- refresh timing --- test('tokenNeedsRefresh respects the skew window', () => { diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts index 691cd32caca..16e2d960f82 100644 --- a/apps/desktop/electron/native-oauth.ts +++ b/apps/desktop/electron/native-oauth.ts @@ -193,6 +193,31 @@ export function parseTokenResponse(body: any): NativeTokenSet { } } +/** + * Validate a token set loaded from the encrypted local store. + * + * The stored representation is already normalized as NativeTokenSet and + * therefore uses camelCase. Gateway token responses use snake_case and + * remain handled separately by parseTokenResponse(). + */ +export function parseStoredTokenSet(body: any): NativeTokenSet { + const accessToken = String(body?.accessToken || '') + + if (!accessToken) { + throw new Error('Stored token set missing accessToken') + } + + const expiresAt = Number(body?.expiresAt) + + return { + accessToken, + refreshToken: String(body?.refreshToken || ''), + expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, + provider: String(body?.provider || ''), + userId: String(body?.userId || '') + } +} + /** * True when a stored token set is at/near expiry and should be refreshed * before use. `skewSeconds` refreshes slightly early to avoid a race where