Skip to content

Commit 01c67c5

Browse files
lsteinCopilotclaudeJPPhoto
authored
Fix (multiuser): Ask user to log back in when security token has expired (#9017)
* Initial plan * Warn user when credentials have expired in multiuser mode Agent-Logs-Url: https://github.com/lstein/InvokeAI/sessions/f0947cda-b15c-475d-b7f4-2d553bdf2cd6 Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address code review: avoid multiple localStorage reads in base query Agent-Logs-Url: https://github.com/lstein/InvokeAI/sessions/f0947cda-b15c-475d-b7f4-2d553bdf2cd6 Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * bugfix(multiuser): ask user to log back in when authentication token expires * feat: sliding window session expiry with token refresh Backend: - SlidingWindowTokenMiddleware refreshes JWT on each mutating request (POST/PUT/PATCH/DELETE), returning a new token in X-Refreshed-Token response header. GET requests don't refresh (they're often background fetches that shouldn't reset the inactivity timer). - CORS expose_headers updated to allow X-Refreshed-Token. Frontend: - dynamicBaseQuery picks up X-Refreshed-Token from responses and updates localStorage so subsequent requests use the fresh expiry. - 401 handler only triggers sessionExpiredLogout when a token was actually sent (not for unauthenticated background requests). - ProtectedRoute polls localStorage every 5s and listens for storage events to detect token removal (e.g. manual deletion, other tabs). Result: session expires after TOKEN_EXPIRATION_NORMAL (1 day) of inactivity, not a fixed time after login. Any user-initiated action resets the clock. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(backend): ruff * fix: address review feedback on auth token handling Bug fixes: - ProtectedRoute: only treat 401 errors as session expiry, not transient 500/network errors that should not force logout - Token refresh: use explicit remember_me claim in JWT instead of inferring from remaining lifetime, preventing silent downgrade of 7-day tokens to 1-day when <24h remains - TokenData: add remember_me field, set during login Tests (6 new): - Mutating requests (POST/PUT/DELETE) return X-Refreshed-Token - GET requests do not return X-Refreshed-Token - Unauthenticated requests do not return X-Refreshed-Token - Remember-me token refreshes to 7-day duration even near expiry - Normal token refreshes to 1-day duration - remember_me claim preserved through refresh cycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(backend): ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
1 parent be015a5 commit 01c67c5

10 files changed

Lines changed: 309 additions & 18 deletions

File tree

invokeai/app/api/routers/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ async def login(
150150
user_id=user.user_id,
151151
email=user.email,
152152
is_admin=user.is_admin,
153+
remember_me=request.remember_me,
153154
)
154155
token = create_access_token(token_data, expires_delta)
155156

invokeai/app/api_app.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,50 @@ async def lifespan(app: FastAPI):
7979
)
8080

8181

82+
class SlidingWindowTokenMiddleware(BaseHTTPMiddleware):
83+
"""Refresh the JWT token on each authenticated response.
84+
85+
When a request includes a valid Bearer token, the response includes a
86+
X-Refreshed-Token header with a new token that has a fresh expiry.
87+
This implements sliding-window session expiry: the session only expires
88+
after a period of *inactivity*, not a fixed time after login.
89+
"""
90+
91+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
92+
response = await call_next(request)
93+
94+
# Only refresh on mutating requests (POST/PUT/PATCH/DELETE) — these indicate
95+
# genuine user activity. GET requests are often background fetches (RTK Query
96+
# cache revalidation, refetch-on-focus, etc.) and should not reset the
97+
# inactivity timer.
98+
if response.status_code < 400 and request.method in ("POST", "PUT", "PATCH", "DELETE"):
99+
auth_header = request.headers.get("authorization", "")
100+
if auth_header.startswith("Bearer "):
101+
token = auth_header[7:]
102+
try:
103+
from datetime import timedelta
104+
105+
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
106+
from invokeai.app.services.auth.token_service import create_access_token, verify_token
107+
108+
token_data = verify_token(token)
109+
if token_data is not None:
110+
# Use the remember_me claim from the token to determine the
111+
# correct refresh duration. This avoids the bug where a 7-day
112+
# token with <24h remaining would be silently downgraded to 1 day.
113+
if token_data.remember_me:
114+
expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME)
115+
else:
116+
expires_delta = timedelta(days=TOKEN_EXPIRATION_NORMAL)
117+
118+
new_token = create_access_token(token_data, expires_delta)
119+
response.headers["X-Refreshed-Token"] = new_token
120+
except Exception:
121+
pass # Don't fail the request if token refresh fails
122+
123+
return response
124+
125+
82126
class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware):
83127
"""When a request is made to the root path with a query string, redirect to the root path without the query string.
84128
@@ -99,6 +143,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
99143

100144
# Add the middleware
101145
app.add_middleware(RedirectRootWithQueryStringMiddleware)
146+
app.add_middleware(SlidingWindowTokenMiddleware)
102147

103148

104149
# Add event handler
@@ -117,6 +162,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
117162
allow_credentials=app_config.allow_credentials,
118163
allow_methods=app_config.allow_methods,
119164
allow_headers=app_config.allow_headers,
165+
expose_headers=["X-Refreshed-Token"],
120166
)
121167

122168
app.add_middleware(GZipMiddleware, minimum_size=1000)

invokeai/app/services/auth/token_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class TokenData(BaseModel):
2121
user_id: str
2222
email: str
2323
is_admin: bool
24+
remember_me: bool = False
2425

2526

2627
def set_jwt_secret(secret: str) -> None:

invokeai/frontend/web/public/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"rememberMe": "Remember me for 7 days",
2626
"signIn": "Sign In",
2727
"signingIn": "Signing in...",
28-
"loginFailed": "Login failed. Please check your credentials."
28+
"loginFailed": "Login failed. Please check your credentials.",
29+
"sessionExpired": "Your credentials have expired. Please log in again to resume."
2930
},
3031
"setup": {
3132
"title": "Welcome to InvokeAI",

invokeai/frontend/web/src/features/auth/components/LoginPage.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
Text,
1414
VStack,
1515
} from '@invoke-ai/ui-library';
16-
import { useAppDispatch } from 'app/store/storeHooks';
17-
import { setCredentials } from 'features/auth/store/authSlice';
16+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
17+
import { selectSessionExpired, setCredentials } from 'features/auth/store/authSlice';
1818
import type { ChangeEvent, FormEvent } from 'react';
1919
import { memo, useCallback, useEffect, useState } from 'react';
2020
import { useTranslation } from 'react-i18next';
@@ -29,6 +29,7 @@ export const LoginPage = memo(() => {
2929
const [rememberMe, setRememberMe] = useState(true);
3030
const [login, { isLoading, error }] = useLoginMutation();
3131
const dispatch = useAppDispatch();
32+
const sessionExpired = useAppSelector(selectSessionExpired);
3233
const { data: setupStatus, isLoading: isLoadingSetup } = useGetSetupStatusQuery();
3334

3435
// Redirect to app if multiuser mode is disabled
@@ -114,6 +115,12 @@ export const LoginPage = memo(() => {
114115
{t('auth.login.title')}
115116
</Heading>
116117

118+
{sessionExpired && (
119+
<Flex p={3} borderRadius="md" bg="warning.600" color="white" fontSize="sm" justifyContent="center">
120+
<Text fontWeight="semibold">{t('auth.login.sessionExpired')}</Text>
121+
</Flex>
122+
)}
123+
117124
<FormControl isRequired isInvalid={!!errorMessage}>
118125
<FormLabel>{t('auth.login.email')}</FormLabel>
119126
<Input

invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Center, Spinner } from '@invoke-ai/ui-library';
22
import type { RootState } from 'app/store/store';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4-
import { logout, setCredentials } from 'features/auth/store/authSlice';
4+
import { logout, sessionExpiredLogout, setCredentials } from 'features/auth/store/authSlice';
55
import type { PropsWithChildren } from 'react';
66
import { memo, useEffect } from 'react';
77
import { useNavigate } from 'react-router-dom';
@@ -33,13 +33,42 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit
3333
});
3434

3535
useEffect(() => {
36-
// If we have a token but fetching user failed, token is invalid - logout
37-
if (userError && isAuthenticated) {
38-
dispatch(logout());
36+
// Only treat 401 as session expiry. Other errors (500, network, etc.) are
37+
// transient and should not force logout — the 401 handler in dynamicBaseQuery
38+
// already covers the actual expiry case.
39+
if (userError && isAuthenticated && 'status' in userError && userError.status === 401) {
40+
dispatch(sessionExpiredLogout());
3941
navigate('/login', { replace: true });
4042
}
4143
}, [userError, isAuthenticated, dispatch, navigate]);
4244

45+
// Detect when auth_token is removed from localStorage (e.g. by another tab,
46+
// browser devtools, or token expiry cleanup). The 'storage' event fires when
47+
// localStorage is modified by another context; we also poll periodically to
48+
// catch same-tab deletions (which don't trigger the storage event).
49+
useEffect(() => {
50+
if (!multiuserEnabled || !isAuthenticated) {
51+
return;
52+
}
53+
54+
const checkToken = () => {
55+
if (!localStorage.getItem('auth_token') && isAuthenticated) {
56+
dispatch(sessionExpiredLogout());
57+
navigate('/login', { replace: true });
58+
}
59+
};
60+
61+
// Listen for cross-tab localStorage changes
62+
window.addEventListener('storage', checkToken);
63+
// Poll for same-tab deletions (e.g. browser console)
64+
const interval = setInterval(checkToken, 5000);
65+
66+
return () => {
67+
window.removeEventListener('storage', checkToken);
68+
clearInterval(interval);
69+
};
70+
}, [multiuserEnabled, isAuthenticated, dispatch, navigate]);
71+
4372
useEffect(() => {
4473
// If we successfully fetched user data, update auth state
4574
if (currentUser && token && !user) {

invokeai/frontend/web/src/features/auth/store/authSlice.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const zAuthState = z.object({
1616
token: z.string().nullable(),
1717
user: zUser.nullable(),
1818
isLoading: z.boolean(),
19+
sessionExpired: z.boolean(),
1920
});
2021

2122
type User = z.infer<typeof zUser>;
@@ -34,6 +35,7 @@ const initialState: AuthState = {
3435
token: getStoredAuthToken(),
3536
user: null,
3637
isLoading: false,
38+
sessionExpired: false,
3739
};
3840

3941
const getInitialAuthState = (): AuthState => initialState;
@@ -46,6 +48,7 @@ const authSlice = createSlice({
4648
state.token = action.payload.token;
4749
state.user = action.payload.user;
4850
state.isAuthenticated = true;
51+
state.sessionExpired = false;
4952
if (typeof window !== 'undefined' && window.localStorage) {
5053
localStorage.setItem('auth_token', action.payload.token);
5154
}
@@ -54,6 +57,16 @@ const authSlice = createSlice({
5457
state.token = null;
5558
state.user = null;
5659
state.isAuthenticated = false;
60+
state.sessionExpired = false;
61+
if (typeof window !== 'undefined' && window.localStorage) {
62+
localStorage.removeItem('auth_token');
63+
}
64+
},
65+
sessionExpiredLogout: (state) => {
66+
state.token = null;
67+
state.user = null;
68+
state.isAuthenticated = false;
69+
state.sessionExpired = true;
5770
if (typeof window !== 'undefined' && window.localStorage) {
5871
localStorage.removeItem('auth_token');
5972
}
@@ -64,7 +77,7 @@ const authSlice = createSlice({
6477
},
6578
});
6679

67-
export const { setCredentials, logout, setLoading } = authSlice.actions;
80+
export const { setCredentials, logout, sessionExpiredLogout, setLoading } = authSlice.actions;
6881

6982
export const authSliceConfig: SliceConfig<typeof authSlice> = {
7083
slice: authSlice,
@@ -73,11 +86,12 @@ export const authSliceConfig: SliceConfig<typeof authSlice> = {
7386
persistConfig: {
7487
migrate: () => getInitialAuthState(),
7588
// Don't persist auth state - token is stored in localStorage
76-
persistDenylist: ['isAuthenticated', 'token', 'user', 'isLoading'],
89+
persistDenylist: ['isAuthenticated', 'token', 'user', 'isLoading', 'sessionExpired'],
7790
},
7891
};
7992

8093
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated;
8194
export const selectCurrentUser = (state: { auth: AuthState }) => state.auth.user;
8295
export const selectAuthToken = (state: { auth: AuthState }) => state.auth.token;
8396
export const selectIsAuthLoading = (state: { auth: AuthState }) => state.auth.isLoading;
97+
export const selectSessionExpired = (state: { auth: AuthState }) => state.auth.sessionExpired;

invokeai/frontend/web/src/services/api/index.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
TagDescription,
88
} from '@reduxjs/toolkit/query/react';
99
import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@reduxjs/toolkit/query/react';
10+
import { sessionExpiredLogout } from 'features/auth/store/authSlice';
1011
import queryString from 'query-string';
1112
import stableHash from 'stable-hash';
1213

@@ -68,22 +69,27 @@ export const getBaseUrl = (): string => {
6869
return window.location.origin;
6970
};
7071

71-
const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = (args, api, extraOptions) => {
72+
const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
73+
args,
74+
api,
75+
extraOptions
76+
) => {
7277
const isOpenAPIRequest =
7378
(args instanceof Object && args.url.includes('openapi.json')) ||
7479
(typeof args === 'string' && args.includes('openapi.json'));
7580

81+
const isAuthEndpoint =
82+
(args instanceof Object &&
83+
typeof args.url === 'string' &&
84+
(args.url.includes('/auth/login') || args.url.includes('/auth/setup'))) ||
85+
(typeof args === 'string' && (args.includes('/auth/login') || args.includes('/auth/setup')));
86+
87+
const token = localStorage.getItem('auth_token');
88+
7689
const fetchBaseQueryArgs: FetchBaseQueryArgs = {
7790
baseUrl: getBaseUrl(),
7891
prepareHeaders: (headers) => {
7992
// Add auth token to all requests except setup and login
80-
const token = localStorage.getItem('auth_token');
81-
const isAuthEndpoint =
82-
(args instanceof Object &&
83-
typeof args.url === 'string' &&
84-
(args.url.includes('/auth/login') || args.url.includes('/auth/setup'))) ||
85-
(typeof args === 'string' && (args.includes('/auth/login') || args.includes('/auth/setup')));
86-
8793
if (token && !isAuthEndpoint) {
8894
headers.set('Authorization', `Bearer ${token}`);
8995
}
@@ -98,7 +104,25 @@ const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryE
98104

99105
const rawBaseQuery = fetchBaseQuery(fetchBaseQueryArgs);
100106

101-
return rawBaseQuery(args, api, extraOptions);
107+
const result = await rawBaseQuery(args, api, extraOptions);
108+
109+
// If we sent an auth token but got 401, the token is invalid/expired.
110+
// Only trigger session expiry when we actually sent a token — unauthenticated
111+
// requests (e.g. client_state queries during page load) should not cause logout.
112+
if (result.error && result.error.status === 401 && !isAuthEndpoint && token) {
113+
api.dispatch(sessionExpiredLogout());
114+
}
115+
116+
// Sliding window token refresh: if the server returned a refreshed token,
117+
// update localStorage so subsequent requests use the new expiry.
118+
if (!result.error && result.meta?.response) {
119+
const refreshedToken = result.meta.response.headers.get('X-Refreshed-Token');
120+
if (refreshedToken) {
121+
localStorage.setItem('auth_token', refreshedToken);
122+
}
123+
}
124+
125+
return result;
102126
};
103127

104128
const createLruSelector = createSelectorCreator({

tests/app/api/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)