From 2735e81dbd0fb6b06dc64d73dd1f22a5d056f7cb Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 04:27:17 +0000 Subject: [PATCH 1/8] feat: powersync hs256 auth --- docs/DATA_SYNC_README.md | 13 ++++++- package.json | 1 + src/lib/powersync/Connector.test.ts | 28 +++++++++++--- src/lib/powersync/Connector.ts | 23 ++++++++---- src/lib/powersync/deviceId.ts | 57 +++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 src/lib/powersync/deviceId.ts diff --git a/docs/DATA_SYNC_README.md b/docs/DATA_SYNC_README.md index 003a3c2b7..cfc4dce74 100644 --- a/docs/DATA_SYNC_README.md +++ b/docs/DATA_SYNC_README.md @@ -31,7 +31,12 @@ This guide will help you set up data synchronization for Calendar Notifications 3. Connect PowerSync to your Supabase database: - Follow the connection instructions in the PowerSync dashboard - Make sure to configure the necessary permissions and access controls -4. Generate a developer token from the PowerSync dashboard +4. Configure HS256 authentication: + - Go to your instance settings → Client Auth tab + - Under "HS256 authentication tokens (ADVANCED)", click the "+" button + - Generate a secret: `openssl rand -base64 32` + - Paste the generated secret and save + - Click "Save and deploy" ### 3. Calendar Notifications Plus Configuration @@ -41,7 +46,9 @@ This guide will help you set up data synchronization for Calendar Notifications - Supabase URL (from step 1.3) - Supabase Anon Key (from step 1.3) - PowerSync Instance URL (from step 2.2) - - PowerSync Token (from step 2.4) + - PowerSync Token (paste the same HS256 secret from step 2.4) + +> **Note:** The app automatically generates short-lived JWT tokens using this secret. Unlike development tokens, the HS256 secret doesn't expire - you only need to configure it once. ## Verification @@ -55,6 +62,8 @@ This guide will help you set up data synchronization for Calendar Notifications - Check the PowerSync status in the app - Ensure your Supabase and PowerSync instances are running and connected - Review the app logs for any error messages +- **JWT errors:** Make sure the HS256 secret in the app matches exactly what's configured in PowerSync dashboard +- **"Invalid token" errors:** Verify the HS256 secret is properly configured under Client Auth → HS256 authentication tokens ## Additional Resources diff --git a/package.json b/package.json index 3d856ba28..fbb6223de 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "expo": "~54.0.0", "expo-application": "~7.0.0", "expo-status-bar": "~3.0.9", + "jose": "^5.9.6", "js-logger": "^1.6.1", "log4js": "^6.9.1", "nativewind": "^4.1.23", diff --git a/src/lib/powersync/Connector.test.ts b/src/lib/powersync/Connector.test.ts index 23f6bc69d..520754560 100644 --- a/src/lib/powersync/Connector.test.ts +++ b/src/lib/powersync/Connector.test.ts @@ -12,6 +12,22 @@ import { FailedOperation, LogFilterLevel, } from './Connector'; +import * as deviceIdModule from './deviceId'; + +// Mock jose SignJWT +jest.mock('jose', () => ({ + SignJWT: jest.fn().mockImplementation(() => ({ + setProtectedHeader: jest.fn().mockReturnThis(), + setIssuedAt: jest.fn().mockReturnThis(), + setExpirationTime: jest.fn().mockReturnThis(), + sign: jest.fn().mockResolvedValue('mock-generated-jwt-token'), + })), +})); + +// Mock deviceId module +jest.mock('./deviceId', () => ({ + getOrCreateDeviceId: jest.fn().mockResolvedValue('mock-device-uuid'), +})); // Type the mocked modules const mockedAsyncStorage = AsyncStorage as jest.Mocked; @@ -84,17 +100,19 @@ describe('Connector', () => { }); describe('fetchCredentials', () => { - it('should return PowerSync credentials from settings', async () => { + it('should generate JWT using HS256 secret and device ID', async () => { const settings = createMockSettings(); mockedCreateClient.mockReturnValue({} as any); const connector = new Connector(settings); const credentials = await connector.fetchCredentials(); - expect(credentials).toEqual({ - endpoint: settings.powersyncUrl, - token: settings.powersyncToken, - }); + // Should return the endpoint and generated JWT + expect(credentials.endpoint).toBe(settings.powersyncUrl); + expect(credentials.token).toBe('mock-generated-jwt-token'); + + // Should have called getOrCreateDeviceId + expect(deviceIdModule.getOrCreateDeviceId).toHaveBeenCalled(); }); }); diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index 3ebb00c1d..99f77a877 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -2,10 +2,12 @@ import 'react-native-url-polyfill/auto' import { UpdateType, AbstractPowerSyncDatabase, PowerSyncBackendConnector, CrudEntry } from '@powersync/react-native'; import { SupabaseClient, createClient, PostgrestSingleResponse } from '@supabase/supabase-js'; +import { SignJWT } from 'jose'; import { Settings } from '../hooks/SettingsContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Logger from 'js-logger'; import { SyncLogEntry, emitSyncLog, emitCapturedLog } from '../logging/syncLog'; +import { getOrCreateDeviceId } from './deviceId'; const log = Logger.get('PowerSync'); @@ -152,14 +154,21 @@ export class Connector implements PowerSyncBackendConnector { async fetchCredentials() { emitSyncLog('debug', 'fetchCredentials called', { endpoint: this.settings.powersyncUrl, - tokenLength: this.settings.powersyncToken?.length || 0, + hasSecret: !!this.settings.powersyncToken, }); - const credentials = { - endpoint: this.settings.powersyncUrl, - token: this.settings.powersyncToken // TODO: programattically generate token from user id (i.e. email or phone number) + random secret - }; - emitSyncLog('debug', 'Returning credentials'); - return credentials; + + // Generate a fresh JWT signed with the HS256 secret + const deviceId = await getOrCreateDeviceId(); + const secret = new TextEncoder().encode(this.settings.powersyncToken); + + const token = await new SignJWT({ sub: deviceId }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') // Short-lived, auto-renewed by PowerSync + .sign(secret); + + emitSyncLog('debug', 'Generated JWT for device', { deviceId }); + return { endpoint: this.settings.powersyncUrl, token }; } private async executeOperation(op: CrudEntry): Promise | null> { diff --git a/src/lib/powersync/deviceId.ts b/src/lib/powersync/deviceId.ts new file mode 100644 index 000000000..4daa46836 --- /dev/null +++ b/src/lib/powersync/deviceId.ts @@ -0,0 +1,57 @@ +/** + * Device ID helper for PowerSync authentication. + * Generates and persists a unique UUID for this device installation. + * + * Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const DEVICE_ID_KEY = '@powersync_device_id'; + +/** + * Generates a UUID v4 without external dependencies. + * Uses crypto.getRandomValues when available, falls back to Math.random. + */ +const generateUUID = (): string => { + // Use crypto API if available (React Native has it via Hermes) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback UUID v4 generation + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + +/** + * Gets the existing device ID or creates a new one. + * The ID is persisted in AsyncStorage and remains stable across app restarts. + */ +export const getOrCreateDeviceId = async (): Promise => { + try { + const existingId = await AsyncStorage.getItem(DEVICE_ID_KEY); + if (existingId) { + return existingId; + } + + const newId = generateUUID(); + await AsyncStorage.setItem(DEVICE_ID_KEY, newId); + return newId; + } catch (error) { + // If storage fails, generate a new ID (won't persist but allows operation) + console.warn('[deviceId] Failed to access AsyncStorage, using ephemeral ID:', error); + return generateUUID(); + } +}; + +/** + * Clears the stored device ID. Useful for testing or reset scenarios. + */ +export const clearDeviceId = async (): Promise => { + await AsyncStorage.removeItem(DEVICE_ID_KEY); +}; + From e74644bf4f70e62ed6585e9be21d7daae2718300 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 05:06:19 +0000 Subject: [PATCH 2/8] fix: yarn lock update --- yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/yarn.lock b/yarn.lock index 2c7d87c8f..a29f36011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6698,6 +6698,7 @@ __metadata: jest: ^29.6.2 jest-expo: ~54.0.0 jest-junit: ^16.0.0 + jose: ^5.9.6 js-logger: ^1.6.1 log4js: ^6.9.1 metro-config: ~0.82.0 @@ -12581,6 +12582,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.9.6": + version: 5.10.0 + resolution: "jose@npm:5.10.0" + checksum: e80965ef3ab47baafac3517f53fa9c74b948b57690de524f51320c314cd545ef51ec7b18761605d58fb5965b7c5e12b2bb6ddae87a6ccf55e3f4ad077347d8d7 + languageName: node + linkType: hard + "js-logger@npm:^1.6.1": version: 1.6.1 resolution: "js-logger@npm:1.6.1" From 4eb0b10af9f4d7ced384d65fcaa4f5d317bb6019 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 05:47:26 +0000 Subject: [PATCH 3/8] fix: it WORKSSS!!!!!! --- docs/DATA_SYNC_README.md | 14 ++++--- package.json | 3 +- src/lib/powersync/Connector.test.ts | 29 +++++++------ src/lib/powersync/Connector.ts | 64 +++++++++++++++++++++++++---- src/lib/powersync/index.tsx | 12 +++++- yarn.lock | 24 +++++++---- 6 files changed, 111 insertions(+), 35 deletions(-) diff --git a/docs/DATA_SYNC_README.md b/docs/DATA_SYNC_README.md index cfc4dce74..7031b3ae1 100644 --- a/docs/DATA_SYNC_README.md +++ b/docs/DATA_SYNC_README.md @@ -33,10 +33,12 @@ This guide will help you set up data synchronization for Calendar Notifications - Make sure to configure the necessary permissions and access controls 4. Configure HS256 authentication: - Go to your instance settings → Client Auth tab - - Under "HS256 authentication tokens (ADVANCED)", click the "+" button - - Generate a secret: `openssl rand -base64 32` - - Paste the generated secret and save - - Click "Save and deploy" + - Under **"JWT Audience (optional)"**, click the "+" button and add: `powersync` + - Under **"HS256 authentication tokens (ADVANCED)"**, click the "+" button + - For **KID**, enter: `powersync` + - For **HS256 secret**, generate one: `openssl rand -base64url 32` + - Copy the generated secret (you'll need it for the app) + - Click **"Save and deploy"** ### 3. Calendar Notifications Plus Configuration @@ -48,7 +50,7 @@ This guide will help you set up data synchronization for Calendar Notifications - PowerSync Instance URL (from step 2.2) - PowerSync Token (paste the same HS256 secret from step 2.4) -> **Note:** The app automatically generates short-lived JWT tokens using this secret. Unlike development tokens, the HS256 secret doesn't expire - you only need to configure it once. +> **Note:** The app automatically generates short-lived JWT tokens (5 min expiry) using this secret. The JWTs include `sub` (device ID), `aud` (powersync), `iat`, and `exp` claims. Unlike development tokens, the HS256 secret doesn't expire - you only need to configure it once. ## Verification @@ -64,6 +66,8 @@ This guide will help you set up data synchronization for Calendar Notifications - Review the app logs for any error messages - **JWT errors:** Make sure the HS256 secret in the app matches exactly what's configured in PowerSync dashboard - **"Invalid token" errors:** Verify the HS256 secret is properly configured under Client Auth → HS256 authentication tokens +- **"Missing aud claim" errors:** Add `powersync` to JWT Audience in the PowerSync dashboard (Client Auth → JWT Audience) +- **"Unexpected aud claim" errors:** The JWT Audience in PowerSync dashboard must include `powersync` ## Additional Resources diff --git a/package.json b/package.json index fbb6223de..bbda0e0f7 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,13 @@ "@testing-library/react-native": "^13.3.3", "await-to-js": "^3.0.0", "commander": "^13.1.0", + "crypto-js": "^4.2.0", "dom-helpers": "^5.2.1", "dotenv": "^16.3.1", "execa": "^9.5.2", "expo": "~54.0.0", "expo-application": "~7.0.0", "expo-status-bar": "~3.0.9", - "jose": "^5.9.6", "js-logger": "^1.6.1", "log4js": "^6.9.1", "nativewind": "^4.1.23", @@ -81,6 +81,7 @@ "@swc-node/register": "^1.6.7", "@testing-library/react-hooks": "^8.0.1", "@tsconfig/react-native": "^3.0.2", + "@types/crypto-js": "^4.2.2", "@types/jest": "^29.5.5", "@types/node": "^22.14.0", "@types/react": "~19.1.0", diff --git a/src/lib/powersync/Connector.test.ts b/src/lib/powersync/Connector.test.ts index 520754560..dd6297fb3 100644 --- a/src/lib/powersync/Connector.test.ts +++ b/src/lib/powersync/Connector.test.ts @@ -14,16 +14,6 @@ import { } from './Connector'; import * as deviceIdModule from './deviceId'; -// Mock jose SignJWT -jest.mock('jose', () => ({ - SignJWT: jest.fn().mockImplementation(() => ({ - setProtectedHeader: jest.fn().mockReturnThis(), - setIssuedAt: jest.fn().mockReturnThis(), - setExpirationTime: jest.fn().mockReturnThis(), - sign: jest.fn().mockResolvedValue('mock-generated-jwt-token'), - })), -})); - // Mock deviceId module jest.mock('./deviceId', () => ({ getOrCreateDeviceId: jest.fn().mockResolvedValue('mock-device-uuid'), @@ -107,12 +97,27 @@ describe('Connector', () => { const connector = new Connector(settings); const credentials = await connector.fetchCredentials(); - // Should return the endpoint and generated JWT + // Should return the endpoint expect(credentials.endpoint).toBe(settings.powersyncUrl); - expect(credentials.token).toBe('mock-generated-jwt-token'); // Should have called getOrCreateDeviceId expect(deviceIdModule.getOrCreateDeviceId).toHaveBeenCalled(); + + // Should return a valid JWT (three base64url parts separated by dots) + const parts = credentials.token.split('.'); + expect(parts).toHaveLength(3); + + // Decode and verify header + const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))); + expect(header.alg).toBe('HS256'); + expect(header.kid).toBe('powersync'); + + // Decode and verify payload has required claims + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + expect(payload.sub).toBe('mock-device-uuid'); + expect(payload.iat).toBeDefined(); + expect(payload.exp).toBeDefined(); + expect(payload.exp).toBeGreaterThan(payload.iat); }); }); diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index 99f77a877..9649c2340 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -2,13 +2,60 @@ import 'react-native-url-polyfill/auto' import { UpdateType, AbstractPowerSyncDatabase, PowerSyncBackendConnector, CrudEntry } from '@powersync/react-native'; import { SupabaseClient, createClient, PostgrestSingleResponse } from '@supabase/supabase-js'; -import { SignJWT } from 'jose'; +import CryptoJS from 'crypto-js'; import { Settings } from '../hooks/SettingsContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Logger from 'js-logger'; import { SyncLogEntry, emitSyncLog, emitCapturedLog } from '../logging/syncLog'; import { getOrCreateDeviceId } from './deviceId'; +/** + * Decodes a base64url encoded string to raw bytes (as a WordArray for crypto-js). + * PowerSync stores HS256 secrets as base64url encoded. + */ +const base64UrlDecode = (str: string): CryptoJS.lib.WordArray => { + // Convert base64url to base64 + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + // Add padding if needed + while (base64.length % 4) { + base64 += '='; + } + return CryptoJS.enc.Base64.parse(base64); +}; + +/** + * Creates a JWT token signed with HS256. + * Uses crypto-js which is pure JavaScript and works in React Native. + * @param payload - JWT payload claims + * @param base64UrlSecret - The HS256 secret (base64url encoded, as stored in PowerSync) + * @param kid - Key ID to include in the JWT header + */ +export const createHS256Token = (payload: Record, base64UrlSecret: string, kid: string): string => { + const header = { alg: 'HS256', typ: 'JWT', kid }; + + const base64UrlEncode = (str: string): string => { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(str)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + }; + + const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)); + const dataToSign = `${headerEncoded}.${payloadEncoded}`; + + // Decode the base64url secret to raw bytes for HMAC signing + const secretBytes = base64UrlDecode(base64UrlSecret); + + const signature = CryptoJS.HmacSHA256(dataToSign, secretBytes); + const signatureEncoded = CryptoJS.enc.Base64.stringify(signature) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + return `${dataToSign}.${signatureEncoded}`; +}; + const log = Logger.get('PowerSync'); /// Postgres Response codes that we cannot recover from by retrying. @@ -159,13 +206,16 @@ export class Connector implements PowerSyncBackendConnector { // Generate a fresh JWT signed with the HS256 secret const deviceId = await getOrCreateDeviceId(); - const secret = new TextEncoder().encode(this.settings.powersyncToken); + const now = Math.floor(Date.now() / 1000); + + const payload = { + sub: deviceId, + aud: 'powersync', // Must match JWT Audience in PowerSync dashboard + iat: now, + exp: now + 300, // 5 minutes, auto-renewed by PowerSync + }; - const token = await new SignJWT({ sub: deviceId }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('5m') // Short-lived, auto-renewed by PowerSync - .sign(secret); + const token = createHS256Token(payload, this.settings.powersyncToken, 'powersync'); emitSyncLog('debug', 'Generated JWT for device', { deviceId }); return { endpoint: this.settings.powersyncUrl, token }; diff --git a/src/lib/powersync/index.tsx b/src/lib/powersync/index.tsx index 77818aa3b..855c13047 100644 --- a/src/lib/powersync/index.tsx +++ b/src/lib/powersync/index.tsx @@ -1,9 +1,10 @@ import { OPSqliteOpenFactory } from '@powersync/op-sqlite'; import { PowerSyncDatabase } from '@powersync/react-native'; -import { Connector } from './Connector'; +import { Connector, createHS256Token } from './Connector'; import { emitSyncLog } from '../logging/syncLog'; import { AppSchema } from './Schema'; import { Settings } from '../hooks/SettingsContext'; +import { getOrCreateDeviceId } from './deviceId'; const factory = new OPSqliteOpenFactory({ dbFilename: 'powerSyncEvents.db', @@ -78,11 +79,18 @@ async function testNetworkConnectivity(settings: Settings): Promise { try { emitSyncLog('debug', 'Testing network connectivity...'); + + // Generate a JWT for authentication (don't send raw secret!) + const deviceId = await getOrCreateDeviceId(); + const now = Math.floor(Date.now() / 1000); + const payload = { sub: deviceId, aud: 'powersync', iat: now, exp: now + 300 }; + const token = createHS256Token(payload, settings.powersyncToken, 'powersync'); + const testUrl = `${settings.powersyncUrl}/sync/stream`; const response = await fetch(testUrl, { method: 'GET', headers: { - 'Authorization': `Bearer ${settings.powersyncToken}`, + 'Authorization': `Bearer ${token}`, }, signal: controller.signal, }); diff --git a/yarn.lock b/yarn.lock index a29f36011..2f5470472 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6142,6 +6142,13 @@ __metadata: languageName: node linkType: hard +"@types/crypto-js@npm:^4.2.2": + version: 4.2.2 + resolution: "@types/crypto-js@npm:4.2.2" + checksum: 727daa0d2db35f0abefbab865c23213b6ee6a270e27e177939bbe4b70d1e84c2202d9fac4ea84859c4b4d49a4ee50f948f601327a39b69ec013288018ba07ca5 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -6673,6 +6680,7 @@ __metadata: "@testing-library/react-hooks": ^8.0.1 "@testing-library/react-native": ^13.3.3 "@tsconfig/react-native": ^3.0.2 + "@types/crypto-js": ^4.2.2 "@types/jest": ^29.5.5 "@types/node": ^22.14.0 "@types/react": ~19.1.0 @@ -6682,6 +6690,7 @@ __metadata: babel-plugin-module-resolver: ^5.0.0 commander: ^13.1.0 conventional-changelog-conventionalcommits: ^8.0.0 + crypto-js: ^4.2.0 dom-helpers: ^5.2.1 dotenv: ^16.3.1 drizzle-kit: ^0.19.13 @@ -6698,7 +6707,6 @@ __metadata: jest: ^29.6.2 jest-expo: ~54.0.0 jest-junit: ^16.0.0 - jose: ^5.9.6 js-logger: ^1.6.1 log4js: ^6.9.1 metro-config: ~0.82.0 @@ -8574,6 +8582,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: f051666dbc077c8324777f44fbd3aaea2986f198fe85092535130d17026c7c2ccf2d23ee5b29b36f7a4a07312db2fae23c9094b644cc35f7858b1b4fcaf27774 + languageName: node + linkType: hard + "crypto-random-string@npm:^2.0.0": version: 2.0.0 resolution: "crypto-random-string@npm:2.0.0" @@ -12582,13 +12597,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:^5.9.6": - version: 5.10.0 - resolution: "jose@npm:5.10.0" - checksum: e80965ef3ab47baafac3517f53fa9c74b948b57690de524f51320c314cd545ef51ec7b18761605d58fb5965b7c5e12b2bb6ddae87a6ccf55e3f4ad077347d8d7 - languageName: node - linkType: hard - "js-logger@npm:^1.6.1": version: 1.6.1 resolution: "js-logger@npm:1.6.1" From 23a9df1c8b7d1314403eaedf3fd7fffab8a033a6 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 05:50:18 +0000 Subject: [PATCH 4/8] fix: label --- src/screens/settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/settings.tsx b/src/screens/settings.tsx index d307a36b3..03457455d 100644 --- a/src/screens/settings.tsx +++ b/src/screens/settings.tsx @@ -214,12 +214,12 @@ export default function Settings() { - PowerSync Token + PowerSync Secret handleSettingChange({ ...tempSettings, powersyncToken: text })} - placeholder="your-powersync-token" + placeholder="HS256 secret from PowerSync dashboard" isVisible={showPowerSyncToken} onVisibilityChange={setShowPowerSyncToken} /> From ee58d9e5ac59f09ef90710ce1faaf40009605d94 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 05:54:23 +0000 Subject: [PATCH 5/8] fix: s/token/secret/g --- env.d.ts | 2 +- src/lib/config.ts | 6 +-- src/lib/env.ts | 2 +- src/lib/features/SetupSync.tsx | 2 +- src/lib/features/__tests__/SetupSync.test.ts | 8 ++-- .../features/__tests__/SetupSync.ui.test.tsx | 8 ++-- src/lib/features/__tests__/__mocks__/env.ts | 2 +- src/lib/hooks/SettingsContext.tsx | 48 ++++++++++++++++--- src/lib/powersync/Connector.test.ts | 2 +- src/lib/powersync/Connector.ts | 4 +- src/lib/powersync/index.tsx | 2 +- src/screens/settings.tsx | 12 ++--- 12 files changed, 67 insertions(+), 31 deletions(-) diff --git a/env.d.ts b/env.d.ts index 599b94fa8..b749362a2 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,7 +4,7 @@ declare module '@env' { export const EXPO_PUBLIC_SUPABASE_ANON_KEY: string | undefined; export const EXPO_PUBLIC_SUPABASE_BUCKET: string | undefined; export const EXPO_PUBLIC_POWERSYNC_URL: string | undefined; - export const EXPO_PUBLIC_POWERSYNC_TOKEN: string | undefined; + export const EXPO_PUBLIC_POWERSYNC_SECRET: string | undefined; export const EXPO_PUBLIC_SYNC_ENABLED: string | undefined; export const EXPO_PUBLIC_SYNC_TYPE: string | undefined; } diff --git a/src/lib/config.ts b/src/lib/config.ts index cb893ffa2..23fe7cdca 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,7 +4,7 @@ import { EXPO_PUBLIC_SUPABASE_ANON_KEY, EXPO_PUBLIC_SUPABASE_BUCKET, EXPO_PUBLIC_POWERSYNC_URL, - EXPO_PUBLIC_POWERSYNC_TOKEN, + EXPO_PUBLIC_POWERSYNC_SECRET, EXPO_PUBLIC_SYNC_ENABLED, EXPO_PUBLIC_SYNC_TYPE, } from '@env'; @@ -17,7 +17,7 @@ if (__DEV__) { SUPABASE_URL: EXPO_PUBLIC_SUPABASE_URL ? 'SET' : 'EMPTY', SUPABASE_ANON_KEY: EXPO_PUBLIC_SUPABASE_ANON_KEY ? 'SET' : 'EMPTY', POWERSYNC_URL: EXPO_PUBLIC_POWERSYNC_URL ? 'SET' : 'EMPTY', - POWERSYNC_TOKEN: EXPO_PUBLIC_POWERSYNC_TOKEN ? 'SET' : 'EMPTY', + POWERSYNC_SECRET: EXPO_PUBLIC_POWERSYNC_SECRET ? 'SET' : 'EMPTY', }); } @@ -29,7 +29,7 @@ export const ConfigObj = { }, powersync:{ url: EXPO_PUBLIC_POWERSYNC_URL || '', - token: EXPO_PUBLIC_POWERSYNC_TOKEN || '' + secret: EXPO_PUBLIC_POWERSYNC_SECRET || '' }, sync: { enabled: EXPO_PUBLIC_SYNC_ENABLED !== 'false', diff --git a/src/lib/env.ts b/src/lib/env.ts index b6569876d..ac37ba725 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -21,7 +21,7 @@ export const getEnv = () => { SUPABASE_ANON_KEY: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_BUCKET: process.env.EXPO_PUBLIC_SUPABASE_BUCKET, POWERSYNC_URL: process.env.EXPO_PUBLIC_POWERSYNC_URL, - POWERSYNC_TOKEN: process.env.EXPO_PUBLIC_POWERSYNC_TOKEN, + POWERSYNC_SECRET: process.env.EXPO_PUBLIC_POWERSYNC_SECRET, SYNC_ENABLED: process.env.EXPO_PUBLIC_SYNC_ENABLED, SYNC_TYPE: process.env.EXPO_PUBLIC_SYNC_TYPE, }; diff --git a/src/lib/features/SetupSync.tsx b/src/lib/features/SetupSync.tsx index a268cc48d..18daf1775 100644 --- a/src/lib/features/SetupSync.tsx +++ b/src/lib/features/SetupSync.tsx @@ -41,7 +41,7 @@ export const isSettingsConfigured = (settings: Settings): boolean => Boolean( settings.supabaseUrl && settings.supabaseAnonKey && settings.powersyncUrl && - settings.powersyncToken + settings.powersyncSecret ); export const SetupSync = () => { diff --git a/src/lib/features/__tests__/SetupSync.test.ts b/src/lib/features/__tests__/SetupSync.test.ts index c106bcc88..232de6569 100644 --- a/src/lib/features/__tests__/SetupSync.test.ts +++ b/src/lib/features/__tests__/SetupSync.test.ts @@ -24,7 +24,7 @@ const createSettings = (overrides: Partial = {}): Settings => ({ supabaseUrl: '', supabaseAnonKey: '', powersyncUrl: '', - powersyncToken: '', + powersyncSecret: '', ...overrides, }); @@ -34,7 +34,7 @@ const createCompleteSettings = (overrides: Partial = {}): Settings => supabaseUrl: 'https://example.supabase.co', supabaseAnonKey: 'anon-key-123', powersyncUrl: 'https://example.powersync.com', - powersyncToken: 'token123', + powersyncSecret: 'token123', ...overrides, }); @@ -56,8 +56,8 @@ describe('SetupSync', () => { expect(isSettingsConfigured(createCompleteSettings({ powersyncUrl: '' }))).toBe(false); }); - it('returns false when powersyncToken is missing', () => { - expect(isSettingsConfigured(createCompleteSettings({ powersyncToken: '' }))).toBe(false); + it('returns false when powersyncSecret is missing', () => { + expect(isSettingsConfigured(createCompleteSettings({ powersyncSecret: '' }))).toBe(false); }); it('returns true when all credentials are provided', () => { diff --git a/src/lib/features/__tests__/SetupSync.ui.test.tsx b/src/lib/features/__tests__/SetupSync.ui.test.tsx index 107aa95cf..2f1e4f229 100644 --- a/src/lib/features/__tests__/SetupSync.ui.test.tsx +++ b/src/lib/features/__tests__/SetupSync.ui.test.tsx @@ -23,7 +23,7 @@ const testState = { supabaseUrl: '', supabaseAnonKey: '', powersyncUrl: '', - powersyncToken: '', + powersyncSecret: '', }, powerSyncStatus: { connected: null as boolean | null, @@ -78,7 +78,7 @@ const configureSettings = (configured: boolean) => { supabaseUrl: 'https://example.supabase.co', supabaseAnonKey: 'anon-key-123', powersyncUrl: 'https://example.powersync.com', - powersyncToken: 'token123', + powersyncSecret: 'token123', }; } else { testState.settings = { @@ -87,7 +87,7 @@ const configureSettings = (configured: boolean) => { supabaseUrl: '', supabaseAnonKey: '', powersyncUrl: '', - powersyncToken: '', + powersyncSecret: '', }; } }; @@ -174,7 +174,7 @@ describe('SetupSync UI States', () => { supabaseUrl: 'https://example.supabase.co', supabaseAnonKey: 'anon-key-123', powersyncUrl: 'https://example.powersync.com', - powersyncToken: 'token123', + powersyncSecret: 'token123', }; configurePowerSyncStatus(null); }); diff --git a/src/lib/features/__tests__/__mocks__/env.ts b/src/lib/features/__tests__/__mocks__/env.ts index dbffc773b..33fe9fbb9 100644 --- a/src/lib/features/__tests__/__mocks__/env.ts +++ b/src/lib/features/__tests__/__mocks__/env.ts @@ -6,7 +6,7 @@ export const EXPO_PUBLIC_SUPABASE_URL = ''; export const EXPO_PUBLIC_SUPABASE_ANON_KEY = ''; export const EXPO_PUBLIC_SUPABASE_BUCKET = ''; export const EXPO_PUBLIC_POWERSYNC_URL = ''; -export const EXPO_PUBLIC_POWERSYNC_TOKEN = ''; +export const EXPO_PUBLIC_POWERSYNC_SECRET = ''; export const EXPO_PUBLIC_SYNC_ENABLED = 'false'; export const EXPO_PUBLIC_SYNC_TYPE = 'unidirectional'; diff --git a/src/lib/hooks/SettingsContext.tsx b/src/lib/hooks/SettingsContext.tsx index 27978ad1d..988078aef 100644 --- a/src/lib/hooks/SettingsContext.tsx +++ b/src/lib/hooks/SettingsContext.tsx @@ -11,7 +11,12 @@ export interface Settings { supabaseUrl: string; supabaseAnonKey: string; powersyncUrl: string; - powersyncToken: string; + powersyncSecret: string; +} + +// Legacy settings interface for migration +interface LegacySettings { + powersyncToken?: string; } const DEFAULT_SETTINGS: Settings = { @@ -20,7 +25,29 @@ const DEFAULT_SETTINGS: Settings = { supabaseUrl: '', supabaseAnonKey: '', powersyncUrl: '', - powersyncToken: '', + powersyncSecret: '', +}; + +/** + * Migrates old settings format to new format. + * - Renames powersyncToken to powersyncSecret (and clears it since old tokens are invalid) + */ +const migrateSettings = (stored: Settings & LegacySettings): Settings => { + const migrated = { ...stored }; + + // If old powersyncToken exists, clear it (old dev tokens won't work with new HS256 auth) + if ('powersyncToken' in migrated) { + delete (migrated as LegacySettings).powersyncToken; + // Don't migrate the value - old tokens are incompatible with new HS256 secret + migrated.powersyncSecret = ''; + } + + // Ensure powersyncSecret exists + if (!('powersyncSecret' in migrated)) { + migrated.powersyncSecret = ''; + } + + return migrated; }; const SETTINGS_STORAGE_KEY = '@calendar_notifications_settings'; @@ -48,10 +75,19 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil if (storedSettingsStr) { const parsedSettings = JSON.parse(storedSettingsStr); + // Migrate old settings format if needed + const migratedSettings = migrateSettings(parsedSettings); if (__DEV__) { - console.log('[SettingsContext] Using stored settings'); + console.log('[SettingsContext] Using stored settings (migrated if needed)'); + } + setSettings(migratedSettings); + // Save migrated settings back + if (JSON.stringify(parsedSettings) !== JSON.stringify(migratedSettings)) { + await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(migratedSettings)); + if (__DEV__) { + console.log('[SettingsContext] Migrated settings saved'); + } } - setSettings(parsedSettings); } else { // Initialize with current ConfigObj values if (__DEV__) { @@ -59,7 +95,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil supabaseUrl: ConfigObj.supabase.url ? 'SET' : 'EMPTY', supabaseAnonKey: ConfigObj.supabase.anonKey ? 'SET' : 'EMPTY', powersyncUrl: ConfigObj.powersync.url ? 'SET' : 'EMPTY', - powersyncToken: ConfigObj.powersync.token ? 'SET' : 'EMPTY', + powersyncSecret: ConfigObj.powersync.secret ? 'SET' : 'EMPTY', }); } const currentSettings: Settings = { @@ -68,7 +104,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil supabaseUrl: ConfigObj.supabase.url, supabaseAnonKey: ConfigObj.supabase.anonKey, powersyncUrl: ConfigObj.powersync.url, - powersyncToken: ConfigObj.powersync.token, + powersyncSecret: ConfigObj.powersync.secret, }; setSettings(currentSettings); await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(currentSettings)); diff --git a/src/lib/powersync/Connector.test.ts b/src/lib/powersync/Connector.test.ts index dd6297fb3..72a1449a1 100644 --- a/src/lib/powersync/Connector.test.ts +++ b/src/lib/powersync/Connector.test.ts @@ -30,7 +30,7 @@ const createMockSettings = () => ({ supabaseUrl: 'https://test.supabase.co', supabaseAnonKey: 'test-anon-key', powersyncUrl: 'https://test.powersync.com', - powersyncToken: 'test-token', + powersyncSecret: 'test-token', }); // Helper to create mock CrudEntry diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index 9649c2340..cf8707467 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -201,7 +201,7 @@ export class Connector implements PowerSyncBackendConnector { async fetchCredentials() { emitSyncLog('debug', 'fetchCredentials called', { endpoint: this.settings.powersyncUrl, - hasSecret: !!this.settings.powersyncToken, + hasSecret: !!this.settings.powersyncSecret, }); // Generate a fresh JWT signed with the HS256 secret @@ -215,7 +215,7 @@ export class Connector implements PowerSyncBackendConnector { exp: now + 300, // 5 minutes, auto-renewed by PowerSync }; - const token = createHS256Token(payload, this.settings.powersyncToken, 'powersync'); + const token = createHS256Token(payload, this.settings.powersyncSecret, 'powersync'); emitSyncLog('debug', 'Generated JWT for device', { deviceId }); return { endpoint: this.settings.powersyncUrl, token }; diff --git a/src/lib/powersync/index.tsx b/src/lib/powersync/index.tsx index 855c13047..0b6a52307 100644 --- a/src/lib/powersync/index.tsx +++ b/src/lib/powersync/index.tsx @@ -84,7 +84,7 @@ async function testNetworkConnectivity(settings: Settings): Promise { const deviceId = await getOrCreateDeviceId(); const now = Math.floor(Date.now() / 1000); const payload = { sub: deviceId, aud: 'powersync', iat: now, exp: now + 300 }; - const token = createHS256Token(payload, settings.powersyncToken, 'powersync'); + const token = createHS256Token(payload, settings.powersyncSecret, 'powersync'); const testUrl = `${settings.powersyncUrl}/sync/stream`; const response = await fetch(testUrl, { diff --git a/src/screens/settings.tsx b/src/screens/settings.tsx index 03457455d..561ff7fd4 100644 --- a/src/screens/settings.tsx +++ b/src/screens/settings.tsx @@ -27,7 +27,7 @@ export default function Settings() { return s.supabaseUrl.trim() !== '' && s.supabaseAnonKey.trim() !== '' && s.powersyncUrl.trim() !== '' && - s.powersyncToken.trim() !== ''; + s.powersyncSecret.trim() !== ''; }; const handleSettingChange = (newSettings: typeof settings) => { @@ -41,14 +41,14 @@ export default function Settings() { supabaseUrl: tempSettings.supabaseUrl.trim(), supabaseAnonKey: tempSettings.supabaseAnonKey.trim(), powersyncUrl: tempSettings.powersyncUrl.trim(), - powersyncToken: tempSettings.powersyncToken.trim() + powersyncSecret: tempSettings.powersyncSecret.trim() }; const hasActualChanges = trimmedSettings.supabaseUrl !== settings.supabaseUrl.trim() || trimmedSettings.supabaseAnonKey !== settings.supabaseAnonKey.trim() || trimmedSettings.powersyncUrl !== settings.powersyncUrl.trim() || - trimmedSettings.powersyncToken !== settings.powersyncToken.trim() || + trimmedSettings.powersyncSecret !== settings.powersyncSecret.trim() || trimmedSettings.syncEnabled !== settings.syncEnabled || trimmedSettings.syncType !== settings.syncType; @@ -143,7 +143,7 @@ export default function Settings() { {JSON.stringify({ ...tempSettings, supabaseAnonKey: showSupabaseKey ? tempSettings.supabaseAnonKey : '[Hidden Reveal Below]', - powersyncToken: showPowerSyncToken ? tempSettings.powersyncToken : '[Hidden Reveal Below]' + powersyncSecret: showPowerSyncToken ? tempSettings.powersyncSecret : '[Hidden Reveal Below]' }, null, 2)} @@ -217,8 +217,8 @@ export default function Settings() { PowerSync Secret handleSettingChange({ ...tempSettings, powersyncToken: text })} + value={tempSettings.powersyncSecret} + onChangeText={(text) => handleSettingChange({ ...tempSettings, powersyncSecret: text })} placeholder="HS256 secret from PowerSync dashboard" isVisible={showPowerSyncToken} onVisibilityChange={setShowPowerSyncToken} From c04e0be1594ae22ce66553f06cbe90fa682798b8 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 05:57:54 +0000 Subject: [PATCH 6/8] docs: .env.example and openssl generate --- .env.example | 6 ++++-- docs/DATA_SYNC_README.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index e2524d603..328cb44a0 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,10 @@ EXPO_PUBLIC_SUPABASE_BUCKET=your_supabase_bucket # PowerSync Configuration EXPO_PUBLIC_POWERSYNC_URL=your_powersync_url -EXPO_PUBLIC_POWERSYNC_TOKEN=your_powersync_token +# HS256 secret from PowerSync dashboard (base64url encoded) +# Generate with: openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' +EXPO_PUBLIC_POWERSYNC_SECRET=your_powersync_hs256_secret # Sync Configuration EXPO_PUBLIC_SYNC_ENABLED=true -EXPO_PUBLIC_SYNC_TYPE=unidirectional \ No newline at end of file +EXPO_PUBLIC_SYNC_TYPE=unidirectional \ No newline at end of file diff --git a/docs/DATA_SYNC_README.md b/docs/DATA_SYNC_README.md index 7031b3ae1..e222e1162 100644 --- a/docs/DATA_SYNC_README.md +++ b/docs/DATA_SYNC_README.md @@ -36,7 +36,7 @@ This guide will help you set up data synchronization for Calendar Notifications - Under **"JWT Audience (optional)"**, click the "+" button and add: `powersync` - Under **"HS256 authentication tokens (ADVANCED)"**, click the "+" button - For **KID**, enter: `powersync` - - For **HS256 secret**, generate one: `openssl rand -base64url 32` + - For **HS256 secret**, generate one: `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='` - Copy the generated secret (you'll need it for the app) - Click **"Save and deploy"** From 22233e36877b094a99ab5913df6de7aa188de280 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 06:07:39 +0000 Subject: [PATCH 7/8] fix: central token gen --- src/lib/powersync/Connector.ts | 38 +++++++++++++++++++++------------- src/lib/powersync/index.tsx | 8 ++----- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index cf8707467..2e8afa3d2 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -30,7 +30,7 @@ const base64UrlDecode = (str: string): CryptoJS.lib.WordArray => { * @param base64UrlSecret - The HS256 secret (base64url encoded, as stored in PowerSync) * @param kid - Key ID to include in the JWT header */ -export const createHS256Token = (payload: Record, base64UrlSecret: string, kid: string): string => { +const createHS256Token = (payload: Record, base64UrlSecret: string, kid: string): string => { const header = { alg: 'HS256', typ: 'JWT', kid }; const base64UrlEncode = (str: string): string => { @@ -56,6 +56,27 @@ export const createHS256Token = (payload: Record, base64UrlSecr return `${dataToSign}.${signatureEncoded}`; }; +/** + * Generates a PowerSync JWT for authentication. + * Centralizes token generation to ensure consistent claims across the app. + * + * @param secret - The HS256 secret (base64url encoded) from PowerSync dashboard + * @returns Promise resolving to the signed JWT string + */ +export const generatePowerSyncJWT = async (secret: string): Promise => { + const deviceId = await getOrCreateDeviceId(); + const now = Math.floor(Date.now() / 1000); + + const payload = { + sub: deviceId, + aud: 'powersync', // Must match JWT Audience in PowerSync dashboard + iat: now, + exp: now + 300, // 5 minutes, auto-renewed by PowerSync + }; + + return createHS256Token(payload, secret, 'powersync'); +}; + const log = Logger.get('PowerSync'); /// Postgres Response codes that we cannot recover from by retrying. @@ -204,20 +225,9 @@ export class Connector implements PowerSyncBackendConnector { hasSecret: !!this.settings.powersyncSecret, }); - // Generate a fresh JWT signed with the HS256 secret - const deviceId = await getOrCreateDeviceId(); - const now = Math.floor(Date.now() / 1000); - - const payload = { - sub: deviceId, - aud: 'powersync', // Must match JWT Audience in PowerSync dashboard - iat: now, - exp: now + 300, // 5 minutes, auto-renewed by PowerSync - }; - - const token = createHS256Token(payload, this.settings.powersyncSecret, 'powersync'); + const token = await generatePowerSyncJWT(this.settings.powersyncSecret); - emitSyncLog('debug', 'Generated JWT for device', { deviceId }); + emitSyncLog('debug', 'Generated JWT for PowerSync'); return { endpoint: this.settings.powersyncUrl, token }; } diff --git a/src/lib/powersync/index.tsx b/src/lib/powersync/index.tsx index 0b6a52307..eae0e9100 100644 --- a/src/lib/powersync/index.tsx +++ b/src/lib/powersync/index.tsx @@ -1,10 +1,9 @@ import { OPSqliteOpenFactory } from '@powersync/op-sqlite'; import { PowerSyncDatabase } from '@powersync/react-native'; -import { Connector, createHS256Token } from './Connector'; +import { Connector, generatePowerSyncJWT } from './Connector'; import { emitSyncLog } from '../logging/syncLog'; import { AppSchema } from './Schema'; import { Settings } from '../hooks/SettingsContext'; -import { getOrCreateDeviceId } from './deviceId'; const factory = new OPSqliteOpenFactory({ dbFilename: 'powerSyncEvents.db', @@ -81,10 +80,7 @@ async function testNetworkConnectivity(settings: Settings): Promise { emitSyncLog('debug', 'Testing network connectivity...'); // Generate a JWT for authentication (don't send raw secret!) - const deviceId = await getOrCreateDeviceId(); - const now = Math.floor(Date.now() / 1000); - const payload = { sub: deviceId, aud: 'powersync', iat: now, exp: now + 300 }; - const token = createHS256Token(payload, settings.powersyncSecret, 'powersync'); + const token = await generatePowerSyncJWT(settings.powersyncSecret); const testUrl = `${settings.powersyncUrl}/sync/stream`; const response = await fetch(testUrl, { From dc236ee5bd39bc1033cf6a42118a67e239df10d5 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 1 Jan 2026 06:15:38 +0000 Subject: [PATCH 8/8] fix: drop migration cruft --- src/lib/hooks/SettingsContext.tsx | 42 +++-------------------------- src/lib/powersync/Connector.test.ts | 8 ++++-- src/lib/powersync/Connector.ts | 21 ++++++++++++--- 3 files changed, 27 insertions(+), 44 deletions(-) diff --git a/src/lib/hooks/SettingsContext.tsx b/src/lib/hooks/SettingsContext.tsx index 988078aef..88a4b62aa 100644 --- a/src/lib/hooks/SettingsContext.tsx +++ b/src/lib/hooks/SettingsContext.tsx @@ -14,11 +14,6 @@ export interface Settings { powersyncSecret: string; } -// Legacy settings interface for migration -interface LegacySettings { - powersyncToken?: string; -} - const DEFAULT_SETTINGS: Settings = { syncEnabled: false, syncType: 'unidirectional', @@ -28,28 +23,6 @@ const DEFAULT_SETTINGS: Settings = { powersyncSecret: '', }; -/** - * Migrates old settings format to new format. - * - Renames powersyncToken to powersyncSecret (and clears it since old tokens are invalid) - */ -const migrateSettings = (stored: Settings & LegacySettings): Settings => { - const migrated = { ...stored }; - - // If old powersyncToken exists, clear it (old dev tokens won't work with new HS256 auth) - if ('powersyncToken' in migrated) { - delete (migrated as LegacySettings).powersyncToken; - // Don't migrate the value - old tokens are incompatible with new HS256 secret - migrated.powersyncSecret = ''; - } - - // Ensure powersyncSecret exists - if (!('powersyncSecret' in migrated)) { - migrated.powersyncSecret = ''; - } - - return migrated; -}; - const SETTINGS_STORAGE_KEY = '@calendar_notifications_settings'; interface SettingsContextType { @@ -75,19 +48,12 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil if (storedSettingsStr) { const parsedSettings = JSON.parse(storedSettingsStr); - // Migrate old settings format if needed - const migratedSettings = migrateSettings(parsedSettings); + // Merge with defaults to handle any missing keys (old keys like powersyncToken are just ignored) + const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...parsedSettings }; if (__DEV__) { - console.log('[SettingsContext] Using stored settings (migrated if needed)'); - } - setSettings(migratedSettings); - // Save migrated settings back - if (JSON.stringify(parsedSettings) !== JSON.stringify(migratedSettings)) { - await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(migratedSettings)); - if (__DEV__) { - console.log('[SettingsContext] Migrated settings saved'); - } + console.log('[SettingsContext] Using stored settings'); } + setSettings(mergedSettings); } else { // Initialize with current ConfigObj values if (__DEV__) { diff --git a/src/lib/powersync/Connector.test.ts b/src/lib/powersync/Connector.test.ts index 72a1449a1..5b9d0c8fd 100644 --- a/src/lib/powersync/Connector.test.ts +++ b/src/lib/powersync/Connector.test.ts @@ -11,6 +11,9 @@ import { getLogFilterLevel, FailedOperation, LogFilterLevel, + POWERSYNC_JWT_AUDIENCE, + POWERSYNC_JWT_KID, + POWERSYNC_JWT_EXPIRY_SECONDS, } from './Connector'; import * as deviceIdModule from './deviceId'; @@ -110,14 +113,15 @@ describe('Connector', () => { // Decode and verify header const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))); expect(header.alg).toBe('HS256'); - expect(header.kid).toBe('powersync'); + expect(header.kid).toBe(POWERSYNC_JWT_KID); // Decode and verify payload has required claims const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); expect(payload.sub).toBe('mock-device-uuid'); + expect(payload.aud).toBe(POWERSYNC_JWT_AUDIENCE); expect(payload.iat).toBeDefined(); expect(payload.exp).toBeDefined(); - expect(payload.exp).toBeGreaterThan(payload.iat); + expect(payload.exp - payload.iat).toBe(POWERSYNC_JWT_EXPIRY_SECONDS); }); }); diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index 2e8afa3d2..432d9982a 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -9,6 +9,19 @@ import Logger from 'js-logger'; import { SyncLogEntry, emitSyncLog, emitCapturedLog } from '../logging/syncLog'; import { getOrCreateDeviceId } from './deviceId'; +// JWT Configuration Constants +/** JWT audience claim - must match "JWT Audience" in PowerSync dashboard */ +export const POWERSYNC_JWT_AUDIENCE = 'powersync'; +/** JWT key ID - must match "KID" in PowerSync dashboard HS256 config */ +export const POWERSYNC_JWT_KID = 'powersync'; +/** JWT expiry time in seconds (5 minutes) - PowerSync auto-renews before expiry */ +export const POWERSYNC_JWT_EXPIRY_SECONDS = 300; +/** Milliseconds per second - for timestamp conversion */ +const MS_PER_SECOND = 1000; + +/** Returns current Unix timestamp in seconds */ +const getCurrentUnixTimestamp = (): number => Math.floor(Date.now() / MS_PER_SECOND); + /** * Decodes a base64url encoded string to raw bytes (as a WordArray for crypto-js). * PowerSync stores HS256 secrets as base64url encoded. @@ -65,16 +78,16 @@ const createHS256Token = (payload: Record, base64UrlSecret: str */ export const generatePowerSyncJWT = async (secret: string): Promise => { const deviceId = await getOrCreateDeviceId(); - const now = Math.floor(Date.now() / 1000); + const now = getCurrentUnixTimestamp(); const payload = { sub: deviceId, - aud: 'powersync', // Must match JWT Audience in PowerSync dashboard + aud: POWERSYNC_JWT_AUDIENCE, iat: now, - exp: now + 300, // 5 minutes, auto-renewed by PowerSync + exp: now + POWERSYNC_JWT_EXPIRY_SECONDS, }; - return createHS256Token(payload, secret, 'powersync'); + return createHS256Token(payload, secret, POWERSYNC_JWT_KID); }; const log = Logger.get('PowerSync');