Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6693e41
fix(auth): revoke privileges immediately on role change, deactivation…
lstein Jul 17, 2026
6b75acf
fix(events): keep server-internal events out of the API schema; fix a…
lstein Jul 20, 2026
742e863
feat(auth): invalidate tokens on password change via a revocation epoch
lstein Jul 31, 2026
d46d3b3
fix(auth): stop special-casing the system user in queued-execution ch…
lstein Jul 31, 2026
77758e6
Merge branch 'main' into fix/multiuser-privilege-revocation
JPPhoto Aug 8, 2026
e9317f6
fix(auth): enforce the last-administrator invariant inside the write …
lstein Aug 8, 2026
e55344f
fix(sockets): a socket dropping mid-loop must not abandon re-authoriz…
lstein Aug 9, 2026
7de5f90
fix(session-processor): re-read the owner before cancelling its runni…
lstein Aug 9, 2026
8c3d78b
fix(tests): stub `configuration` in the device-pin test's invoker
lstein Aug 9, 2026
78359a3
fix(tests): add token_epoch to the last-admin fixture's users table
lstein Aug 9, 2026
10a2226
fix(auth): stop the system account from laundering away the last-admi…
lstein Aug 9, 2026
0c0f875
fix(session-processor): honor the access-changed event when the owner…
lstein Aug 9, 2026
76dc920
fix(auth): demote the system account on databases where it was promoted
lstein Aug 9, 2026
8bfcb88
test(sockets): describe the mid-loop test by what it actually pins
lstein Aug 9, 2026
1762d2d
chore(typegen): regenerate for the update_user docstring change
lstein Aug 9, 2026
336cc95
Merge branch 'main' into fix/multiuser-privilege-revocation
lstein Aug 9, 2026
90c2fd9
Merge branch 'main' into fix/multiuser-privilege-revocation
JPPhoto Aug 9, 2026
f765f31
fix(auth): close the system account's login path in all three directions
lstein Aug 9, 2026
b74d34e
fix(session-processor): fail closed when the queue item's owner canno…
lstein Aug 9, 2026
68525ec
fix(sockets): revalidate open sockets against the database
lstein Aug 9, 2026
8a3d38d
docs(multiuser): describe live revocation, not just token expiry
lstein Aug 9, 2026
49d00b3
fix(auth): route the sliding-window refresh through resolve_authorize…
lstein Aug 9, 2026
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
12 changes: 11 additions & 1 deletion docs/src/content/docs/features/Multi-User Mode/admin-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,17 @@ This system uses stateless JWT tokens with HMAC signatures to identify users aft

At the client side, tokens are stored in browser localStorage. Logging out clears them. No server-side session storage is required.

The tokens include the user's ID, email, and admin status, along with an HMAC signature.
The tokens include the user's ID, email, admin status, and a revocation counter, along with an HMAC signature.

**Revocation:** A token proves *identity* only — expiry is not the only thing that can end a session. Every authenticated request re-reads the account from the database, so administrative changes take effect on the target's next request, without waiting for their token to expire and without requiring them to log out:

- **Role changes.** The admin status carried in a token is ignored; each request uses the role currently recorded for the account. A demoted administrator loses administrative endpoints immediately, and a promoted user gains them without logging in again.
- **Deactivation and deletion.** Requests from a deactivated or deleted account are rejected, its open connections are closed, and any of its queue items are canceled — running ones stop at the next node, and pending ones are rejected when they reach the front of the queue. Work already completed is not undone.
- **Password changes.** Changing or resetting a password increments the account's revocation counter, which invalidates every token issued before it. All of that account's other sessions are signed out, including any stolen token. The session that performed the change is issued a replacement automatically and stays signed in.

Changes made through the web UI apply to open connections immediately. Changes made with the `invoke-usermod` and `invoke-userdel` commands run in a separate process from the server: everything the server derives per request (above) still changes the instant the command commits, but connections that are already open are re-checked on a periodic sweep, so closing them can take up to about a minute.

Two limits are worth stating plainly. Revocation is enforced by this server, so a token remains cryptographically valid until it expires — anything that accepts these tokens without consulting the database would still honor it. And a session already in flight is stopped, not rewound: media already delivered to the client and outputs already written stay written.

### Secret Key Management

Expand Down
103 changes: 88 additions & 15 deletions invokeai/app/api/auth_dependencies.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,105 @@
"""FastAPI dependencies for authentication."""

from typing import Annotated
from typing import TYPE_CHECKING, Annotated

from fastapi import Cookie, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.auth.token_service import TokenData, verify_token
from invokeai.app.services.users.users_common import SYSTEM_USER_ID
from invokeai.backend.util.logging import logging

if TYPE_CHECKING:
from invokeai.app.services.users.users_common import UserDTO

logger = logging.getLogger(__name__)

# HTTP Bearer token security scheme
security = HTTPBearer(auto_error=False)
MEDIA_TOKEN_COOKIE = "invokeai_media_token"
# Deliberately indistinguishable from an ordinary expiry to a client: a token that fails
# the epoch check is simply no longer valid, and saying *why* would tell a holder of a
# stolen token that the account's password was just rotated.
TOKEN_REVOKED_DETAIL = "Invalid or expired authentication token"


def resolve_authorized_user(token_data: TokenData) -> "UserDTO | None":
"""Return the account a verified token still grants access to, or None.

This is the single place that decides whether a syntactically valid token is still
honored, and every authenticated entry point must go through it: the REST
dependencies below, the Socket.IO handshake, and the video-upload ASGI gate. Keeping
the rules in one function is deliberate — they were previously repeated at each call
site, and a check added to some copies but not others is indistinguishable from no
check at all on the paths that were missed.

A token is honored when all four hold:

- it does not claim the internal ``system`` account,
- the account still exists,
- it is active,
- and the token carries the account's current revocation epoch. Any mismatch counts
as revoked: the token was not issued from the record as it now stands. Tokens
predating the claim decode to 0 and so remain valid against a record that has never
been bumped, which is why upgrading logs nobody out.

Raises whatever the user service raises; callers that must fail closed should catch.
"""
# `system` owns everything carried over from before multiuser support, and is not a
# login account: `UserService.authenticate` refuses it and the migration clears any
# password left on the row. Neither of those reaches a token that was *already issued*
# — on an instance that set a password through the old `PATCH /auth/users/system` hole
# and logged in before it was closed, the JWT survives the upgrade, and nothing else
# here would reject it: the row is deliberately kept active and its epoch still
# matches, so the sliding-window middleware would renew it indefinitely. Refusing the
# id is what actually ends those sessions, and it holds for databases that applied an
# earlier revision of the migration too.
#
# Single-user mode, where everything legitimately runs as `system`, never reaches here:
# its dependencies synthesize the TokenData and return before resolving anything (see
# `get_current_user_or_default`, `get_current_media_user_or_default`, and
# `_identify_video_upload_user`). So this refuses only real, minted tokens.
if token_data.user_id == SYSTEM_USER_ID:
return None
user = ApiDependencies.invoker.services.users.get(token_data.user_id)
if user is None or not user.is_active:
return None
if token_data.token_epoch != user.token_epoch:
return None
return user


def _validate_token(token: str, invalid_detail: str) -> TokenData:
token_data = verify_token(token)
if token_data is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=invalid_detail)

user = ApiDependencies.invoker.services.users.get(token_data.user_id)
if user is None or not user.is_active:
user = resolve_authorized_user(token_data)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return token_data
return _db_derived_token_data(token_data, user)


def _db_derived_token_data(token_data: TokenData, user: "UserDTO") -> TokenData:
"""Build TokenData whose authorization fields come from the database record.

The JWT proves *identity* only. Authorization (``is_admin``) must reflect the
current database state on every request; otherwise a demoted administrator
keeps admin rights until their token expires — and sliding-window refresh
would renew that stale claim indefinitely. A promoted user symmetrically
gains admin rights on their next request without re-login.

The epoch is carried through from the record so a refreshed token stays valid
(callers only reach here once ``_token_epoch_is_current`` has passed).
"""
return TokenData(
user_id=user.user_id,
email=user.email,
is_admin=user.is_admin,
remember_me=token_data.remember_me,
token_epoch=user.token_epoch,
)


async def get_current_user(
Expand Down Expand Up @@ -62,18 +137,17 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)

# Verify user still exists and is active
user_service = ApiDependencies.invoker.services.users
user = user_service.get(token_data.user_id)
# Verify the token still grants access: user exists, is active, epoch is current.
user = resolve_authorized_user(token_data)

if user is None or not user.is_active:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is inactive or does not exist",
headers={"WWW-Authenticate": "Bearer"},
)

return token_data
return _db_derived_token_data(token_data, user)


async def get_current_user_or_default(
Expand Down Expand Up @@ -117,15 +191,14 @@ async def get_current_user_or_default(
# Invalid token in multiuser mode - reject
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")

# Verify user still exists and is active
user_service = ApiDependencies.invoker.services.users
user = user_service.get(token_data.user_id)
# Verify the token still grants access: user exists, is active, epoch is current.
user = resolve_authorized_user(token_data)

if user is None or not user.is_active:
# User doesn't exist or is inactive in multiuser mode - reject
if user is None:
# Missing, inactive, or revoked in multiuser mode - reject
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")

return token_data
return _db_derived_token_data(token_data, user)


async def get_current_media_user_or_default(
Expand Down
Loading
Loading