diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index be15a6b..8e9d125 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,6 +1,8 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; +import { AppState } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import api from '../services/api'; +import api, { isAuthCollection, isRevokedAuthError } from '../services/api'; +import { getJwtExpiryMs } from '../services/jwtExpiry'; import { syncDevicesToWidget } from '../services/widgetSync'; import { AuthResponse, User } from '../types'; @@ -17,6 +19,22 @@ interface AuthContextType { const AuthContext = createContext(undefined); +const AUTH_COLLECTION_KEY = 'auth_collection'; +const REFRESH_BEFORE_EXPIRY_MS = 60_000; +const MAX_REFRESH_DELAY_MS = 2_147_483_647; +const REFRESH_RETRY_MS = 30_000; +const REFRESH_BACKOFF_MS = 15_000; + +function msUntilRefresh(token: string): number | null { + const expiresAt = getJwtExpiryMs(token); + if (expiresAt == null) return null; + return expiresAt - Date.now() - REFRESH_BEFORE_EXPIRY_MS; +} + +function collectionFromUser(user: User | null): 'users' | '_superusers' | null { + return isAuthCollection(user?.collectionName) ? user.collectionName : null; +} + export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [serverAddress, setServerAddress] = useState(null); @@ -24,44 +42,192 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [isLoading, setIsLoading] = useState(true); const [canCreate, setCanCreate] = useState(null); - const clearSession = useCallback(async () => { - await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']); + const refreshTimerRef = useRef | null>(null); + const refreshingRef = useRef(false); + const sessionWriteRef = useRef>(Promise.resolve()); + const refreshSessionRef = useRef<() => Promise>(async () => {}); + const armRefreshTimerRef = useRef<(token: string, overdueDelay?: number) => void>(() => {}); + const enqueueSessionWrite = useCallback((task: () => Promise) => { + const run = sessionWriteRef.current.then(task, task); + sessionWriteRef.current = run.then( + () => undefined, + () => undefined, + ); + return run; + }, []); + + const clearRefreshTimer = useCallback(() => { + if (refreshTimerRef.current != null) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, []); + + const clearSession = useCallback(async () => { + clearRefreshTimer(); api.clearToken(); api.clearCanCreate(); + api.clearAuthCollection(); setToken(null); setUser(null); setCanCreate(null); + + await enqueueSessionWrite(async () => { + await AsyncStorage.multiRemove([ + 'auth_token', + 'auth_user', + 'auth_can_create', + AUTH_COLLECTION_KEY, + ]); + }); + }, [clearRefreshTimer, enqueueSessionWrite]); + + const armRefreshTimer = useCallback((nextToken: string, overdueDelay = 0) => { + clearRefreshTimer(); + const untilRefresh = msUntilRefresh(nextToken); + if (untilRefresh == null) return; + + const delay = untilRefresh <= 0 ? overdueDelay : Math.min(untilRefresh, MAX_REFRESH_DELAY_MS); + const rescheduleOnly = untilRefresh > MAX_REFRESH_DELAY_MS; + + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + const latest = api.getToken(); + if (!latest) return; + if (rescheduleOnly) { + armRefreshTimerRef.current(latest); + return; + } + void refreshSessionRef.current(); + }, delay); + }, [clearRefreshTimer]); + + const refreshSession = useCallback(async () => { + if (refreshingRef.current || !api.getToken()) return; + + refreshingRef.current = true; + try { + await api.refreshAuth(); + } catch { + // The failure listener either keeps the session and retries, or signs out. + } finally { + refreshingRef.current = false; + } + }, []); + + const handleRefreshFailure = useCallback(async (error: unknown) => { + if (isRevokedAuthError(error)) { + await clearSession(); + return; + } + + if (!api.getToken()) return; + + clearRefreshTimer(); + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + void refreshSessionRef.current(); + }, REFRESH_RETRY_MS); + }, [clearRefreshTimer, clearSession]); + + const persistRefreshedAuth = useCallback(async (auth: AuthResponse) => { + await enqueueSessionWrite(async () => { + if (api.getToken() !== auth.token) return; + + const collection = api.getAuthCollection(); + const pairs: [string, string][] = [ + ['auth_token', auth.token], + ['auth_user', JSON.stringify(auth.record)], + ]; + if (collection) { + pairs.push([AUTH_COLLECTION_KEY, collection]); + } + if (collection === '_superusers') { + pairs.push(['auth_can_create', 'true']); + } + + await AsyncStorage.multiSet(pairs); + if (api.getToken() !== auth.token) return; + + setToken(auth.token); + setUser(auth.record); + if (collection === '_superusers') { + setCanCreate(true); + } + armRefreshTimerRef.current(auth.token, REFRESH_BACKOFF_MS); + }); + }, [enqueueSessionWrite]); + + const watchToken = useCallback((nextToken: string) => { + const untilRefresh = msUntilRefresh(nextToken); + if (untilRefresh != null && untilRefresh <= 0) { + void refreshSessionRef.current(); + return; + } + armRefreshTimerRef.current(nextToken); }, []); + useEffect(() => { + refreshSessionRef.current = refreshSession; + armRefreshTimerRef.current = armRefreshTimer; + }, [armRefreshTimer, refreshSession]); + useEffect(() => { api.setUnauthorizedHandler(clearSession); + api.setAuthUpdateListener(persistRefreshedAuth); + api.setAuthFailureListener(handleRefreshFailure); return () => { api.setUnauthorizedHandler(null); + api.setAuthUpdateListener(null); + api.setAuthFailureListener(null); + clearRefreshTimer(); }; - }, [clearSession]); + }, [clearRefreshTimer, clearSession, handleRefreshFailure, persistRefreshedAuth]); useEffect(() => { let cancelled = false; - void AsyncStorage.multiGet([ - 'auth_token', - 'auth_user', - 'auth_server_address', - 'auth_can_create', - ]).then( - ([[, storedToken], [, storedUser], [, storedServerAddress], [, storedCanCreate]]) => { + void (async () => { + try { + const [ + [, storedToken], + [, storedUser], + [, storedServerAddress], + [, storedCanCreate], + [, storedCollection], + ] = await AsyncStorage.multiGet([ + 'auth_token', + 'auth_user', + 'auth_server_address', + 'auth_can_create', + AUTH_COLLECTION_KEY, + ]); if (cancelled) return; - if (storedCanCreate) { - setCanCreate(storedCanCreate === 'true'); + let restoredUser: User | null = null; + if (storedToken && storedUser) { + restoredUser = JSON.parse(storedUser) as User; + api.setToken(storedToken); + setToken(storedToken); + setUser(restoredUser); } - if (storedToken && storedUser) { - setToken(storedToken); - api.setToken(storedToken); - setUser(JSON.parse(storedUser)); + const collection = isAuthCollection(storedCollection) + ? storedCollection + : collectionFromUser(restoredUser); + if (collection) { + api.setAuthCollection(collection); + } + + if (collection === '_superusers') { + api.setCanCreate(true); + setCanCreate(true); + } else if (storedCanCreate) { + const restoredCanCreate = storedCanCreate === 'true'; + api.setCanCreate(restoredCanCreate); + setCanCreate(restoredCanCreate); } if (storedServerAddress) { @@ -69,36 +235,78 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children api.setAddress(storedServerAddress + '/api'); } - setIsLoading(false); - }, - error => { + const untilRefresh = storedToken ? msUntilRefresh(storedToken) : null; + const refreshIsDue = !!storedToken && !!restoredUser && untilRefresh != null && untilRefresh <= 0; + + if (refreshIsDue) { + try { + // Hold the loading gate so an expired token does not flash the device list. + await api.refreshAuth(); + } catch { + // Revoked tokens sign out; transient failures keep the session and retry. + } + } else if (storedToken && restoredUser) { + watchToken(storedToken); + } + + if (!cancelled) { + setIsLoading(false); + } + } catch (error) { if (cancelled) return; console.error('Failed to load auth', error); setIsLoading(false); } - ); + })(); return () => { cancelled = true; }; + }, [watchToken]); + + useEffect(() => { + const subscription = AppState.addEventListener('change', nextState => { + if (nextState !== 'active') return; + + const currentToken = api.getToken(); + if (!currentToken) return; + + const untilRefresh = msUntilRefresh(currentToken); + if (untilRefresh == null) return; + if (untilRefresh <= 0) { + void refreshSessionRef.current(); + return; + } + armRefreshTimerRef.current(currentToken); + }); + + return () => { + subscription.remove(); + }; }, []); - const login = async (serverAddress: string, identity: string, password: string) => { - try { - const response: AuthResponse = await api.authenticate(serverAddress, identity, password); - - await AsyncStorage.setItem('auth_token', response.token); - await AsyncStorage.setItem('auth_user', JSON.stringify(response.record)); - await AsyncStorage.setItem('auth_server_address', serverAddress); - await AsyncStorage.setItem('auth_can_create', api.getCanCreate() ? 'true' : 'false'); - - setToken(response.token); - setUser(response.record); - setServerAddress(serverAddress); - setCanCreate(api.getCanCreate() || false); - } catch (error) { - throw error; - } + const login = async (nextServerAddress: string, identity: string, password: string) => { + const response: AuthResponse = await api.authenticate(nextServerAddress, identity, password); + const collection = api.getAuthCollection(); + + await enqueueSessionWrite(async () => { + const pairs: [string, string][] = [ + ['auth_token', response.token], + ['auth_user', JSON.stringify(response.record)], + ['auth_server_address', nextServerAddress], + ['auth_can_create', api.getCanCreate() ? 'true' : 'false'], + ]; + if (collection) { + pairs.push([AUTH_COLLECTION_KEY, collection]); + } + await AsyncStorage.multiSet(pairs); + }); + + setToken(response.token); + setUser(response.record); + setServerAddress(nextServerAddress); + setCanCreate(api.getCanCreate() || false); + watchToken(response.token); }; const logout = async () => { diff --git a/src/services/api.ts b/src/services/api.ts index 5420f04..2fa797a 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -5,12 +5,47 @@ import { PermissionResponse, } from '../types'; +export type AuthCollection = 'users' | '_superusers'; + +export type ApiError = Error & { + status?: number; + isAuthError?: boolean; +}; + +export type AuthUpdateListener = (auth: AuthResponse) => void | Promise; +export type AuthFailureListener = (error: unknown) => void | Promise; + +const AUTH_ERROR_MESSAGE = /authorization token|invalid token|unauthorized|not authenticated/i; + +export function isAuthCollection(value: unknown): value is AuthCollection { + return value === 'users' || value === '_superusers'; +} + +export function isRevokedAuthError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const status = (error as { status?: unknown }).status; + return status === 401 || status === 403 || status === 404; +} + +function isSessionChanged(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { sessionChanged?: boolean }).sessionChanged === true + ); +} + class UpSnapAPI { private token: string | null = null; private address: string | null = null; private canCreate: boolean | null = null; + private authCollection: AuthCollection | null = null; + private authEpoch = 0; private unauthorizedHandler: (() => Promise) | null = null; + private authUpdateListener: AuthUpdateListener | null = null; + private authFailureListener: AuthFailureListener | null = null; private isHandlingUnauthorized = false; + private refreshInFlight: Promise | null = null; setToken(token: string) { this.token = token; @@ -22,6 +57,7 @@ class UpSnapAPI { clearToken() { this.token = null; + this.authEpoch += 1; } setAddress(address: string) { @@ -48,10 +84,30 @@ class UpSnapAPI { this.canCreate = null; } + setAuthCollection(collection: AuthCollection | null) { + this.authCollection = collection; + } + + getAuthCollection(): AuthCollection | null { + return this.authCollection; + } + + clearAuthCollection() { + this.authCollection = null; + } + setUnauthorizedHandler(handler: (() => Promise) | null) { this.unauthorizedHandler = handler; } + setAuthUpdateListener(listener: AuthUpdateListener | null) { + this.authUpdateListener = listener; + } + + setAuthFailureListener(listener: AuthFailureListener | null) { + this.authFailureListener = listener; + } + private async handleUnauthorized() { if (!this.unauthorizedHandler || this.isHandlingUnauthorized) { return; @@ -66,7 +122,7 @@ class UpSnapAPI { } } - private async throwApiError(response: Response, fallbackMessage: string): Promise { + private async errorFromResponse(response: Response, fallbackMessage: string): Promise { let message = fallbackMessage; try { @@ -76,23 +132,26 @@ class UpSnapAPI { // Ignore JSON parsing errors and use the fallback message. } - const isAuthError = + const apiError = new Error(message) as ApiError; + apiError.status = response.status; + apiError.isAuthError = response.status === 401 || response.status === 403 || - /authorization token|invalid token|unauthorized|not authenticated/i.test(message); + AUTH_ERROR_MESSAGE.test(message); + return apiError; + } - if (isAuthError) { + private async throwApiError( + response: Response, + fallbackMessage: string, + skipUnauthorizedHandler = false, + ): Promise { + const error = await this.errorFromResponse(response, fallbackMessage); + + if (error.isAuthError && !skipUnauthorizedHandler) { await this.handleUnauthorized(); } - const error = new Error(message) as Error & { - status?: number; - isAuthError?: boolean; - }; - - error.status = response.status; - error.isAuthError = isAuthError; - throw error; } @@ -108,11 +167,166 @@ class UpSnapAPI { return headers; } + private sessionChanged(): ApiError { + const error = new Error('Auth session changed') as ApiError & { sessionChanged: true }; + error.sessionChanged = true; + error.isAuthError = true; + return error; + } + + /** + * One refresh is shared by the expiry timer and every request that receives a 401. + * PocketBase returns a new auth token and record for the same collection. + */ + async refreshAuth(): Promise { + if (!this.refreshInFlight) { + this.refreshInFlight = this.performRefresh().finally(() => { + this.refreshInFlight = null; + }); + } + + return this.refreshInFlight; + } + + private async performRefresh(): Promise { + const epoch = this.authEpoch; + const tokenAtStart = this.token; + + try { + if (!tokenAtStart || !this.address) { + throw Object.assign(new Error('Not authenticated'), { + status: 401, + isAuthError: true, + }) as ApiError; + } + + const collections: AuthCollection[] = this.authCollection + ? [this.authCollection] + : ['users', '_superusers']; + let lastError: ApiError | null = null; + + for (let index = 0; index < collections.length; index += 1) { + const collection = collections[index]; + const response = await fetch( + `${this.address}/collections/${collection}/auth-refresh`, + { + method: 'POST', + headers: this.getHeaders(), + }, + ); + + if (this.authEpoch !== epoch || this.token !== tokenAtStart) { + throw this.sessionChanged(); + } + + if (response.ok) { + const data = (await response.json()) as AuthResponse; + if (this.authEpoch !== epoch || this.token !== tokenAtStart) { + throw this.sessionChanged(); + } + if (!data?.token) { + throw Object.assign(new Error('Auth refresh failed'), { + status: 401, + isAuthError: true, + }) as ApiError; + } + + this.authCollection = isAuthCollection(data.record?.collectionName) + ? data.record.collectionName + : collection; + this.token = data.token; + if (this.authCollection === '_superusers') { + this.canCreate = true; + } + + try { + await this.authUpdateListener?.(data); + } catch (error) { + console.error('Failed to persist refreshed auth', error); + } + + return data; + } + + lastError = await this.errorFromResponse(response, 'Auth refresh failed'); + const tryNextCollection = + !this.authCollection && index < collections.length - 1 && response.status === 403; + if (!tryNextCollection) { + throw lastError; + } + } + + throw ( + lastError ?? + (Object.assign(new Error('Auth refresh failed'), { + status: 401, + isAuthError: true, + }) as ApiError) + ); + } catch (error) { + if (!isSessionChanged(error)) { + try { + await this.authFailureListener?.(error); + } catch (listenerError) { + console.error('Failed to handle auth refresh failure', listenerError); + } + } + throw error; + } + } + + /** + * On 401, refresh once and retry the original request. + * A second 401, or a refresh the server rejects, ends the session. + */ + private async request(url: string, fallbackMessage: string, init?: RequestInit): Promise { + const send = () => + fetch(url, { + ...init, + headers: this.getHeaders(), + }); + + const response = await send(); + if (response.ok) { + return response; + } + + if (response.status === 401 && this.token) { + try { + await this.refreshAuth(); + } catch (error) { + if (isSessionChanged(error)) { + throw error; + } + if (!isRevokedAuthError(error)) { + throw error; + } + await this.handleUnauthorized(); + return this.throwApiError(response, fallbackMessage, true); + } + + const retried = await send(); + if (!retried.ok) { + if (retried.status === 401) { + await this.handleUnauthorized(); + return this.throwApiError(retried, fallbackMessage, true); + } + return this.throwApiError(retried, fallbackMessage); + } + return retried; + } + + return this.throwApiError(response, fallbackMessage); + } + async authenticate( serverAddress: string, identity: string, password: string ): Promise { + this.authEpoch += 1; + this.token = null; + this.authCollection = null; this.address = serverAddress + '/api'; const response = await fetch( @@ -125,7 +339,7 @@ class UpSnapAPI { ); if (!response.ok) { - const response = await fetch( + const superuserResponse = await fetch( `${this.address}/collections/_superusers/auth-with-password`, { method: 'POST', @@ -134,18 +348,20 @@ class UpSnapAPI { } ); - if (!response.ok) { - await this.throwApiError(response, 'Authentication failed'); + if (!superuserResponse.ok) { + await this.throwApiError(superuserResponse, 'Authentication failed'); } - const data: AuthResponse = await response.json(); + const data: AuthResponse = await superuserResponse.json(); this.token = data.token; + this.authCollection = '_superusers'; this.canCreate = true; return data; } const data: AuthResponse = await response.json(); this.token = data.token; + this.authCollection = 'users'; const userID = data.record.id; const userPermissionResponse = await fetch( @@ -165,142 +381,80 @@ class UpSnapAPI { } async getDevices(page = 1, perPage = 100): Promise { - const response = await fetch( + const response = await this.request( `${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`, - { - headers: this.getHeaders(), - } + 'Failed to fetch devices', ); - if (!response.ok) { - await this.throwApiError(response, 'Failed to fetch devices'); - } - const data = await response.json(); return data.items; } async getDevice(id: string): Promise { - const response = await fetch( + const response = await this.request( `${this.address}/collections/devices/records/${id}`, - { - headers: this.getHeaders(), - } + 'Failed to fetch device', ); - if (!response.ok) { - await this.throwApiError(response, 'Failed to fetch device'); - } - return response.json(); } async createDevice(device: Partial): Promise { - const response = await fetch( + const response = await this.request( `${this.address}/collections/devices/records`, + 'Failed to create device', { method: 'POST', - headers: this.getHeaders(), body: JSON.stringify(device), - } + }, ); - if (!response.ok) { - await this.throwApiError(response, 'Failed to create device'); - } - return response.json(); } async updateDevice(id: string, device: Partial): Promise { - const response = await fetch( + const response = await this.request( `${this.address}/collections/devices/records/${id}`, + 'Failed to update device', { method: 'PATCH', - headers: this.getHeaders(), body: JSON.stringify(device), - } + }, ); - if (!response.ok) { - await this.throwApiError(response, 'Failed to update device'); - } - return response.json(); } async deleteDevice(id: string): Promise { - const response = await fetch( + await this.request( `${this.address}/collections/devices/records/${id}`, - { - method: 'DELETE', - headers: this.getHeaders(), - } + 'Failed to delete device', + { method: 'DELETE' }, ); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to delete device'); - } } async wakeDevice(id: string): Promise { - const response = await fetch(`${this.address}/upsnap/wake/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to wake device'); - } + await this.request(`${this.address}/upsnap/wake/${id}`, 'Failed to wake device'); } async wakeGroup(id: string): Promise { - const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to wake group'); - } + await this.request(`${this.address}/upsnap/wakegroup/${id}`, 'Failed to wake group'); } async sleepDevice(id: string): Promise { - const response = await fetch(`${this.address}/upsnap/sleep/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to sleep device'); - } + await this.request(`${this.address}/upsnap/sleep/${id}`, 'Failed to sleep device'); } async rebootDevice(id: string): Promise { - const response = await fetch(`${this.address}/upsnap/reboot/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to reboot device'); - } + await this.request(`${this.address}/upsnap/reboot/${id}`, 'Failed to reboot device'); } async shutdownDevice(id: string): Promise { - const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to shutdown device'); - } + await this.request(`${this.address}/upsnap/shutdown/${id}`, 'Failed to shutdown device'); } async scanNetwork(): Promise { - const response = await fetch(`${this.address}/upsnap/scan`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - await this.throwApiError(response, 'Failed to scan network'); - } + const response = await this.request(`${this.address}/upsnap/scan`, 'Failed to scan network'); const data = await response.json(); if (data.devices && Array.isArray(data.devices)) { diff --git a/src/services/jwtExpiry.ts b/src/services/jwtExpiry.ts new file mode 100644 index 0000000..426e358 --- /dev/null +++ b/src/services/jwtExpiry.ts @@ -0,0 +1,41 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function decodeBase64Url(segment: string): string { + const normalized = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const bytes: number[] = []; + + for (let index = 0; index < padded.length; index += 4) { + const a = BASE64_ALPHABET.indexOf(padded[index]); + const b = BASE64_ALPHABET.indexOf(padded[index + 1]); + const c = padded[index + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 2]); + const d = padded[index + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 3]); + if (a < 0 || b < 0 || c < 0 || d < 0) { + throw new Error('Invalid base64url'); + } + + const chunk = (a << 18) | (b << 12) | (c << 6) | d; + bytes.push((chunk >> 16) & 255); + if (padded[index + 2] !== '=') bytes.push((chunk >> 8) & 255); + if (padded[index + 3] !== '=') bytes.push(chunk & 255); + } + + return String.fromCharCode(...bytes); +} + +/** + * Reads the JWT `exp` claim in milliseconds. + * Scheduling only; the signature is not verified. + */ +export function getJwtExpiryMs(token: string): number | null { + const segment = token.split('.')[1]; + if (!segment) return null; + + try { + const payload = JSON.parse(decodeBase64Url(segment)) as { exp?: unknown }; + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null; + return payload.exp * 1000; + } catch { + return null; + } +}