Enhance Authentication Handling for Slicer Integration
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced separate session management for the slicer application, ensuring that slicer sessions do not interfere with standard authentication.
- Added utility functions for managing slicer authentication sessions, including retrieval, setting, and clearing of session data.
- Updated AuthProvider to handle slicer-specific authentication logic, improving user experience and session integrity.
- Enhanced ApiServerContext to manage socket connections based on the slicer authentication token, ensuring proper connection handling.
This commit is contained in:
Tom Butcher 2026-11-08 23:22:04 +00:00
parent 763bbf6f6e
commit 15f7a86c47
3 changed files with 124 additions and 4 deletions

View File

@ -717,12 +717,17 @@ const ApiServerProvider = ({ children }) => {
const connectToServer = useCallback(() => { const connectToServer = useCallback(() => {
if (token && authenticated == true) { if (token && authenticated == true) {
if (socketRef.current) { if (socketRef.current?.authToken === token) {
if (!socketRef.current.connected) { if (!socketRef.current.connected) {
socketRef.current.connect() socketRef.current.connect()
} }
return return
} }
if (socketRef.current) {
const previousSocket = socketRef.current
socketRef.current = null
previousSocket.disconnect()
}
logger.debug('Token is available, connecting to api server...') logger.debug('Token is available, connecting to api server...')
@ -738,6 +743,7 @@ const ApiServerProvider = ({ children }) => {
if (typeof newSocket.io?.reconnection === 'function') { if (typeof newSocket.io?.reconnection === 'function') {
newSocket.io.reconnection(false) newSocket.io.reconnection(false)
} }
newSocket.authToken = token
setConnecting(true) setConnecting(true)

View File

@ -30,6 +30,9 @@ import {
getAuthCookies, getAuthCookies,
setAuthCookies, setAuthCookies,
clearAuthCookies, clearAuthCookies,
getSlicerAuthSession,
setSlicerAuthSession,
clearSlicerAuthSession,
areCookiesEnabled, areCookiesEnabled,
validateAuthCookies, validateAuthCookies,
setupCookieSync, setupCookieSync,
@ -68,6 +71,30 @@ const AuthProvider = ({ children }) => {
const [notificationApi, notificationContextHolder] = const [notificationApi, notificationContextHolder] =
notification.useNotification() notification.useNotification()
const [initialWebAuth] = useState(() => { const [initialWebAuth] = useState(() => {
// The slicer opens through a one-time app-password link. Never reuse a
// saved FarmControl login, which expires and replaces that session.
if (window.location.pathname === '/slicer') {
const hasAuthCode = new URLSearchParams(window.location.search).has(
'authCode'
)
const slicerSession = hasAuthCode ? null : getSlicerAuthSession()
if (slicerSession?.token) {
return {
retrieved: true,
token: slicerSession.token,
expiresAt: slicerSession.expiresAt,
user: slicerSession.user,
authenticated: true
}
}
return {
retrieved: true,
token: null,
expiresAt: null,
user: null,
authenticated: false
}
}
if (detectIsElectron()) { if (detectIsElectron()) {
return { retrieved: false } return { retrieved: false }
} }
@ -134,9 +161,13 @@ const AuthProvider = ({ children }) => {
}) })
sessionRef.current = { token, expiresAt, userProfile, authenticated } sessionRef.current = { token, expiresAt, userProfile, authenticated }
const isDesktopHelperRoute = location.pathname.startsWith('/desktop') const isDesktopHelperRoute = location.pathname.startsWith('/desktop')
const isSlicerRoute = location.pathname === '/slicer'
const slicerAppKey = getSlicerAppKey(location) const slicerAppKey = getSlicerAppKey(location)
const suppressUnauthorizedModal = const suppressUnauthorizedModal =
isDesktopHelperRoute || Boolean(slicerAppKey) || slicerAuthPending isDesktopHelperRoute ||
isSlicerRoute ||
Boolean(slicerAppKey) ||
slicerAuthPending
var redirectType = 'web' var redirectType = 'web'
@ -183,6 +214,13 @@ const AuthProvider = ({ children }) => {
const persistSession = useCallback( const persistSession = useCallback(
async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => { async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => {
if (locationRef.current?.pathname === '/slicer') {
return setSlicerAuthSession({
access_token: nextToken,
expires_at: nextExpiresAt,
user: nextUser
})
}
if (isElectron) { if (isElectron) {
return await setAuthSession({ return await setAuthSession({
token: nextToken, token: nextToken,
@ -200,6 +238,9 @@ const AuthProvider = ({ children }) => {
) )
const clearPersistedSession = useCallback(async () => { const clearPersistedSession = useCallback(async () => {
if (locationRef.current?.pathname === '/slicer') {
return clearSlicerAuthSession()
}
if (isElectron) { if (isElectron) {
return await clearAuthSession() return await clearAuthSession()
} }
@ -283,14 +324,20 @@ const AuthProvider = ({ children }) => {
// An auth code represents a newly completed login and must take precedence // An auth code represents a newly completed login and must take precedence
// over any existing persisted session. // over any existing persisted session.
if ( if (
location.pathname === '/slicer' ||
location.pathname === '/applaunch' || location.pathname === '/applaunch' ||
location.pathname.startsWith('/desktop') || location.pathname.startsWith('/desktop') ||
authCode != null authCode != null
) { ) {
if (location.pathname.startsWith('/desktop')) { if (
location.pathname === '/slicer' ||
location.pathname.startsWith('/desktop')
) {
setRetreivedTokenFromCookies(true) setRetreivedTokenFromCookies(true)
if (location.pathname.startsWith('/desktop')) {
setInitialized(true) setInitialized(true)
} }
}
return return
} }
@ -306,6 +353,7 @@ const AuthProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (isElectron) return if (isElectron) return
const cleanupCookieSync = setupCookieSync(() => { const cleanupCookieSync = setupCookieSync(() => {
if (locationRef.current?.pathname === '/slicer') return
try { try {
if (validateAuthCookies()) { if (validateAuthCookies()) {
const { const {
@ -658,6 +706,10 @@ const AuthProvider = ({ children }) => {
let intervalId let intervalId
const tokenRefresh = () => { const tokenRefresh = () => {
if (locationRef.current?.pathname === '/slicer') {
notificationApi.destroy('token-expiration')
return
}
if (expiresAt) { if (expiresAt) {
const now = new Date() const now = new Date()
const expirationDate = new Date(expiresAt) const expirationDate = new Date(expiresAt)
@ -878,6 +930,7 @@ const AuthProvider = ({ children }) => {
open={ open={
showSessionExpiredModal && showSessionExpiredModal &&
!isDesktopHelperRoute && !isDesktopHelperRoute &&
!isSlicerRoute &&
!loading && !loading &&
!showAuthErrorModal !showAuthErrorModal
} }

View File

@ -7,6 +7,7 @@ const COOKIE_OPTIONS = {
} }
const AUTH_STORAGE_KEY = 'farmcontrol.auth' const AUTH_STORAGE_KEY = 'farmcontrol.auth'
const SLICER_AUTH_STORAGE_KEY = 'farmcontrol.slicerAuth'
const LEGACY_AUTH_COOKIE_NAMES = ['authToken', 'authExpiresAt', 'user'] const LEGACY_AUTH_COOKIE_NAMES = ['authToken', 'authExpiresAt', 'user']
const getAuthExpiryTime = (expiresAt) => { const getAuthExpiryTime = (expiresAt) => {
@ -325,3 +326,63 @@ export const clearAuthCookies = () => {
} }
clearLegacyAuthCookies() clearLegacyAuthCookies()
} }
const readStoredSession = (storageKey) => {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return null
const session = parseStoredSession(raw)
const expirationTime = getAuthExpiryTime(session.expiresAt)
if (
!session.token ||
!Number.isFinite(expirationTime) ||
expirationTime <= Date.now()
) {
window.localStorage.removeItem(storageKey)
return null
}
return session
}
/**
* Slicer app-password sessions are stored apart from the normal login so a
* short-lived FarmControl session cannot replace or expire them.
*/
export const getSlicerAuthSession = () => {
try {
return readStoredSession(SLICER_AUTH_STORAGE_KEY)
} catch (error) {
console.error('Error reading slicer auth session:', error)
return null
}
}
export const setSlicerAuthSession = (authData) => {
try {
if (!authData?.access_token || !authData?.expires_at) {
console.warn('Auth data is required')
return false
}
const existing = getSlicerAuthSession()
const serialized = serializeAuthSession(authData, existing?.user)
if (window.localStorage.getItem(SLICER_AUTH_STORAGE_KEY) === serialized) {
return true
}
window.localStorage.setItem(SLICER_AUTH_STORAGE_KEY, serialized)
return true
} catch (error) {
console.error('Error setting slicer auth session:', error)
return false
}
}
export const clearSlicerAuthSession = () => {
try {
window.localStorage.removeItem(SLICER_AUTH_STORAGE_KEY)
return true
} catch (error) {
console.error('Error clearing slicer auth session:', error)
return false
}
}