Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions desktop/src/generated/synkronus-client/docs/DefaultApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,8 @@ No authorization required
| **200** | Authentication successful | - |
| **400** | Bad request | - |
| **401** | Authentication failed | - |
| **413** | Authentication request exceeds the configured size limit | - |
| **429** | Too many authentication attempts | * Retry-After - Seconds until the client should retry <br> |

[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)

Expand Down Expand Up @@ -1390,6 +1392,8 @@ No authorization required
| **200** | Token refresh successful | - |
| **400** | Bad request | - |
| **401** | Invalid or expired refresh token | - |
| **413** | Authentication request exceeds the configured size limit | - |
| **429** | Too many authentication attempts | * Retry-After - Seconds until the client should retry <br> |

[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)

Expand Down Expand Up @@ -1790,6 +1794,8 @@ example().catch(console.error);
| **200** | Successful upload | - |
| **400** | Bad request (missing or invalid file) | - |
| **401** | Unauthorized | - |
| **403** | Authenticated account does not have write access | - |
| **413** | Attachment or multipart request exceeds the configured upload limit | - |
| **409** | Conflict — attachment already exists, or repository_generation mismatch (epoch; align before upload) | - |

[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md)
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/services/synk/GeneratedSyncGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type SyncPullRequest,
type SyncPushRequest,
} from '../../generated/synkronus-client';
import { SyncHttpError } from './syncErrors';
import { parseRetryAfter, SyncHttpError } from './syncErrors';
import {
DEFAULT_OBSERVATION_FORM_TYPE,
DEFAULT_OBSERVATION_FORM_VERSION,
Expand Down Expand Up @@ -176,11 +176,19 @@ async function toSyncGatewayError(
const statusLine =
`${error.response.status} ${error.response.statusText}`.trim();
const endpoint = error.response.url || baseUrl;
const retryAfterSeconds = parseRetryAfter(
error.response.headers.get('retry-after'),
);
const detailSuffix = responseDetails ? ` | ${responseDetails}` : '';
const retrySuffix =
error.response.status === 429 && retryAfterSeconds !== undefined
? ` | retry after ${retryAfterSeconds}s`
: '';
return new SyncHttpError(
`Synk ${operation} failed (HTTP ${statusLine}) at ${endpoint}${detailSuffix}`,
`Synk ${operation} failed (HTTP ${statusLine}) at ${endpoint}${detailSuffix}${retrySuffix}`,
error.response.status,
operation,
retryAfterSeconds,
);
}

Expand Down
20 changes: 20 additions & 0 deletions desktop/src/services/synk/syncErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { parseRetryAfter, SyncHttpError } from './syncErrors';

describe('sync auth errors', () => {
it('parses Retry-After seconds and dates', () => {
expect(parseRetryAfter('12')).toBe(12);
expect(
parseRetryAfter(
'Wed, 21 Oct 2015 07:28:10 GMT',
Date.parse('Wed, 21 Oct 2015 07:28:00 GMT'),
),
).toBe(10);
});

it('retains retry metadata without treating 429 as unauthorized', () => {
const error = new SyncHttpError('rate limited', 429, 'refresh', 10);
expect(error.retryAfterSeconds).toBe(10);
expect(error.status).toBe(429);
});
});
15 changes: 15 additions & 0 deletions desktop/src/services/synk/syncErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,34 @@ export type SyncGatewayOperation = 'login' | 'refresh' | 'pull' | 'push';
export class SyncHttpError extends Error {
readonly status: number;
readonly operation: SyncGatewayOperation;
readonly retryAfterSeconds?: number;

constructor(
message: string,
status: number,
operation: SyncGatewayOperation,
retryAfterSeconds?: number,
) {
super(message);
this.name = 'SyncHttpError';
this.status = status;
this.operation = operation;
this.retryAfterSeconds = retryAfterSeconds;
}
}

export function parseRetryAfter(
value: string | null | undefined,
now = Date.now(),
): number | undefined {
if (!value) return undefined;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
const date = Date.parse(value);
if (Number.isNaN(date)) return undefined;
return Math.max(0, Math.ceil((date - now) / 1000));
}

export function isSyncHttpUnauthorized(error: unknown): boolean {
return (
error instanceof SyncHttpError &&
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/store/useCustodianStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ async function reauthenticateActiveProfile(
set({ authSessionsByProfileId: merged });
return;
} catch (refreshError) {
// Only an invalid refresh credential should fall back to password login.
// Retrying immediately after 429 or a transient failure would amplify load.
if (!isSyncHttpUnauthorized(refreshError)) {
throw refreshError;
}
const cred = await tauriClient.credentialGet(id);
const password = cred.password ?? '';
if (!password.trim()) {
Expand Down
95 changes: 72 additions & 23 deletions formulus/src/api/synkronus/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { ODE_VERSION } from '../../version';
import { logger } from '../../diagnostics/logger';
import { invalidateSettingsHydrationCache } from '../../services/SettingsHydrationCache';

export type UserRole = 'read-only' | 'read-write' | 'admin';

Expand Down Expand Up @@ -97,35 +98,87 @@ function decodeJwtPayload(token: string) {
}
}

const AUTH_STORAGE_KEYS = [
'@token',
'@refreshToken',
'@tokenExpiresAt',
'@user',
];

const getHttpStatus = (error: unknown): number | undefined => {
const httpError = error as HttpError | undefined;
return (
httpError?.response?.status ??
httpError?.status ??
httpError?.statusCode ??
httpError?.body?.status ??
httpError?.data?.status
);
};

const clearSession = async (): Promise<void> => {
synkronusApi.clearTokenCache();
invalidateSettingsHydrationCache();
await AsyncStorage.multiRemove(AUTH_STORAGE_KEYS);
};

const removeCredentialsIfMatching = async (
username: string,
password: string,
): Promise<void> => {
try {
const saved = await Keychain.getGenericPassword();
if (saved && saved.username === username && saved.password === password) {
await Keychain.resetGenericPassword();
}
} catch (error) {
console.warn('Failed to remove rejected saved credentials:', error);
} finally {
invalidateSettingsHydrationCache();
}
};

export const login = async (
username: string,
password: string,
): Promise<UserInfo> => {
logger.info('auth', 'login ok');
const api = await synkronusApi.getApi();

synkronusApi.clearTokenCache();

const res = await api.login({
xOdeVersion: ODE_VERSION,
loginRequest: { username, password },
});
let res;
try {
res = await api.login({
xOdeVersion: ODE_VERSION,
loginRequest: { username, password },
});
} catch (error) {
// A concrete login HTTP 401 confirms that these credentials are invalid.
// Transient failures and compatibility errors must leave the prior session intact.
if (getHttpStatus(error) === 401) {
await clearSession();
await removeCredentialsIfMatching(username, password);
}
throw error;
}

const { token, refreshToken: refreshTokenValue, expiresAt } = res.data;

// Authentication has succeeded, so it is now safe to replace saved credentials.
await Keychain.setGenericPassword(username, password);
invalidateSettingsHydrationCache();

await AsyncStorage.setItem('@token', token);
await AsyncStorage.setItem('@refreshToken', refreshTokenValue);
await AsyncStorage.setItem('@tokenExpiresAt', expiresAt.toString());

// Decode JWT to get user info
const claims = decodeJwtPayload(token);
const userInfo: UserInfo = {
username: claims?.username || username,
role: claims?.role || 'read-only',
};

// Store user info
await AsyncStorage.setItem('@user', JSON.stringify(userInfo));
synkronusApi.clearTokenCache();
logger.info('auth', 'login ok');

return userInfo;
};
Expand All @@ -143,12 +196,13 @@ export const getUserInfo = async (): Promise<UserInfo | null> => {
};

export const logout = async (): Promise<void> => {
await AsyncStorage.multiRemove([
'@token',
'@refreshToken',
'@tokenExpiresAt',
'@user',
synkronusApi.clearTokenCache();
invalidateSettingsHydrationCache();
await Promise.all([
AsyncStorage.multiRemove(AUTH_STORAGE_KEYS),
Keychain.resetGenericPassword(),
]);
invalidateSettingsHydrationCache();
};

// Function to retrieve the auth token from AsyncStorage
Expand Down Expand Up @@ -222,15 +276,7 @@ export const isUnauthorizedError = (error: unknown): boolean => {

const httpError = error as HttpError;

// Axios errors: error.response.status
if (httpError.response?.status === 401) return true;

// Direct status properties
if (httpError.status === 401 || httpError.statusCode === 401) return true;

// ProblemDetail format (from OpenAPI spec)
if (httpError.body?.status === 401 || httpError.data?.status === 401)
return true;
if (getHttpStatus(error) === 401) return true;

// Check error message for 401 or unauthorized
if (typeof httpError.message === 'string') {
Expand All @@ -246,6 +292,9 @@ export const isUnauthorizedError = (error: unknown): boolean => {
return false;
};

export const isRateLimitedError = (error: unknown): boolean =>
getHttpStatus(error) === 429;

/** User-visible explanation when the server rejects observation upload (e.g. read-only role). */
export const SYNC_WRITE_FORBIDDEN_MESSAGE =
'You do not have permission to upload observations. Confirm with your administrator that your account has write access, or sign in with an account that can submit data.';
Expand Down
80 changes: 80 additions & 0 deletions formulus/src/api/synkronus/__tests__/Auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ import * as Keychain from 'react-native-keychain';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
autoLogin,
isRateLimitedError,
isUnauthorizedError,
isVersionMismatchError,
login,
logout,
} from '../Auth';
import { VersionMismatchError } from '../../../errors/VersionMismatchError';
import { ODE_VERSION } from '../../../version';
Expand Down Expand Up @@ -130,6 +133,83 @@ describe('Auth - Auto-Login', () => {
});
});

describe('isRateLimitedError', () => {
test('detects 429 without treating it as unauthorized', () => {
const error = { response: { status: 429 } };
expect(isRateLimitedError(error)).toBe(true);
expect(isUnauthorizedError(error)).toBe(false);
});
});

describe('session lifecycle', () => {
test('confirmed invalid credentials clear the prior session and rejected saved credentials', async () => {
const credentials = { username: 'testuser', password: 'wrong' };
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue(credentials);
const mockApi = {
login: jest.fn().mockRejectedValue({ response: { status: 401 } }),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);

await expect(
login(credentials.username, credentials.password),
).rejects.toEqual({
response: { status: 401 },
});

expect(AsyncStorage.multiRemove).toHaveBeenCalledWith([
'@token',
'@refreshToken',
'@tokenExpiresAt',
'@user',
]);
expect(Keychain.resetGenericPassword).toHaveBeenCalled();
expect(synkronusApi.clearTokenCache).toHaveBeenCalled();
});

test('rate limiting preserves the prior session and credentials', async () => {
const mockApi = {
login: jest.fn().mockRejectedValue({ response: { status: 429 } }),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);

await expect(login('testuser', 'password')).rejects.toEqual({
response: { status: 429 },
});

expect(AsyncStorage.multiRemove).not.toHaveBeenCalled();
expect(Keychain.resetGenericPassword).not.toHaveBeenCalled();
expect(Keychain.setGenericPassword).not.toHaveBeenCalled();
});

test('successful login persists credentials only after authentication succeeds', async () => {
const mockApi = {
login: jest.fn().mockResolvedValue({
data: {
token: 'token',
refreshToken: 'refresh',
expiresAt: 123,
},
}),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);

await login('testuser', 'password');

expect(mockApi.login).toHaveBeenCalled();
expect(Keychain.setGenericPassword).toHaveBeenCalledWith(
'testuser',
'password',
);
});

test('logout clears session and saved credentials', async () => {
await logout();
expect(AsyncStorage.multiRemove).toHaveBeenCalled();
expect(Keychain.resetGenericPassword).toHaveBeenCalled();
expect(synkronusApi.clearTokenCache).toHaveBeenCalled();
});
});

describe('isVersionMismatchError', () => {
test('should detect VersionMismatchError instance', () => {
const error = new VersionMismatchError(
Expand Down
Loading
Loading