diff --git a/.env.example b/.env.example index e2524d60..328cb44a 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 003a3c2b..e222e116 100644 --- a/docs/DATA_SYNC_README.md +++ b/docs/DATA_SYNC_README.md @@ -31,7 +31,14 @@ 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 **"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 -base64 32 | tr '+/' '-_' | tr -d '='` + - Copy the generated secret (you'll need it for the app) + - Click **"Save and deploy"** ### 3. Calendar Notifications Plus Configuration @@ -41,7 +48,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 (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 @@ -55,6 +64,10 @@ 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 +- **"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/env.d.ts b/env.d.ts index 599b94fa..b749362a 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/package.json b/package.json index 3d856ba2..bbda0e0f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@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", @@ -80,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/config.ts b/src/lib/config.ts index cb893ffa..23fe7cdc 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 b6569876..ac37ba72 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 a268cc48..18daf177 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 c106bcc8..232de656 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 107aa95c..2f1e4f22 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 dbffc773..33fe9fbb 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 27978ad1..88a4b62a 100644 --- a/src/lib/hooks/SettingsContext.tsx +++ b/src/lib/hooks/SettingsContext.tsx @@ -11,7 +11,7 @@ export interface Settings { supabaseUrl: string; supabaseAnonKey: string; powersyncUrl: string; - powersyncToken: string; + powersyncSecret: string; } const DEFAULT_SETTINGS: Settings = { @@ -20,7 +20,7 @@ const DEFAULT_SETTINGS: Settings = { supabaseUrl: '', supabaseAnonKey: '', powersyncUrl: '', - powersyncToken: '', + powersyncSecret: '', }; const SETTINGS_STORAGE_KEY = '@calendar_notifications_settings'; @@ -48,10 +48,12 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil if (storedSettingsStr) { const parsedSettings = JSON.parse(storedSettingsStr); + // 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'); } - setSettings(parsedSettings); + setSettings(mergedSettings); } else { // Initialize with current ConfigObj values if (__DEV__) { @@ -59,7 +61,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 +70,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 23f6bc69..5b9d0c8f 100644 --- a/src/lib/powersync/Connector.test.ts +++ b/src/lib/powersync/Connector.test.ts @@ -11,7 +11,16 @@ import { getLogFilterLevel, FailedOperation, LogFilterLevel, + POWERSYNC_JWT_AUDIENCE, + POWERSYNC_JWT_KID, + POWERSYNC_JWT_EXPIRY_SECONDS, } from './Connector'; +import * as deviceIdModule from './deviceId'; + +// Mock deviceId module +jest.mock('./deviceId', () => ({ + getOrCreateDeviceId: jest.fn().mockResolvedValue('mock-device-uuid'), +})); // Type the mocked modules const mockedAsyncStorage = AsyncStorage as jest.Mocked; @@ -24,7 +33,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 @@ -84,17 +93,35 @@ 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 + expect(credentials.endpoint).toBe(settings.powersyncUrl); + + // 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_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 - payload.iat).toBe(POWERSYNC_JWT_EXPIRY_SECONDS); }); }); diff --git a/src/lib/powersync/Connector.ts b/src/lib/powersync/Connector.ts index 3ebb00c1..432d9982 100644 --- a/src/lib/powersync/Connector.ts +++ b/src/lib/powersync/Connector.ts @@ -2,10 +2,93 @@ import 'react-native-url-polyfill/auto' import { UpdateType, AbstractPowerSyncDatabase, PowerSyncBackendConnector, CrudEntry } from '@powersync/react-native'; import { SupabaseClient, createClient, PostgrestSingleResponse } from '@supabase/supabase-js'; +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'; + +// 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. + */ +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 + */ +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}`; +}; + +/** + * 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 = getCurrentUnixTimestamp(); + + const payload = { + sub: deviceId, + aud: POWERSYNC_JWT_AUDIENCE, + iat: now, + exp: now + POWERSYNC_JWT_EXPIRY_SECONDS, + }; + + return createHS256Token(payload, secret, POWERSYNC_JWT_KID); +}; const log = Logger.get('PowerSync'); @@ -152,14 +235,13 @@ export class Connector implements PowerSyncBackendConnector { async fetchCredentials() { emitSyncLog('debug', 'fetchCredentials called', { endpoint: this.settings.powersyncUrl, - tokenLength: this.settings.powersyncToken?.length || 0, + hasSecret: !!this.settings.powersyncSecret, }); - 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; + + const token = await generatePowerSyncJWT(this.settings.powersyncSecret); + + emitSyncLog('debug', 'Generated JWT for PowerSync'); + 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 00000000..4daa4683 --- /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); +}; + diff --git a/src/lib/powersync/index.tsx b/src/lib/powersync/index.tsx index 77818aa3..eae0e910 100644 --- a/src/lib/powersync/index.tsx +++ b/src/lib/powersync/index.tsx @@ -1,6 +1,6 @@ import { OPSqliteOpenFactory } from '@powersync/op-sqlite'; import { PowerSyncDatabase } from '@powersync/react-native'; -import { Connector } from './Connector'; +import { Connector, generatePowerSyncJWT } from './Connector'; import { emitSyncLog } from '../logging/syncLog'; import { AppSchema } from './Schema'; import { Settings } from '../hooks/SettingsContext'; @@ -78,11 +78,15 @@ async function testNetworkConnectivity(settings: Settings): Promise { try { emitSyncLog('debug', 'Testing network connectivity...'); + + // Generate a JWT for authentication (don't send raw secret!) + const token = await generatePowerSyncJWT(settings.powersyncSecret); + 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/src/screens/settings.tsx b/src/screens/settings.tsx index d307a36b..561ff7fd 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)} @@ -214,12 +214,12 @@ export default function Settings() { - PowerSync Token + PowerSync Secret handleSettingChange({ ...tempSettings, powersyncToken: text })} - placeholder="your-powersync-token" + value={tempSettings.powersyncSecret} + onChangeText={(text) => handleSettingChange({ ...tempSettings, powersyncSecret: text })} + placeholder="HS256 secret from PowerSync dashboard" isVisible={showPowerSyncToken} onVisibilityChange={setShowPowerSyncToken} /> diff --git a/yarn.lock b/yarn.lock index 2c7d87c8..2f547047 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 @@ -8573,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"