Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
28 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
592fee4
fix(sockets): fail closed on privileges when the record cannot be read
lstein Aug 13, 2026
ee9b769
Merge branch 'main' into fix/multiuser-privilege-revocation
lstein Aug 13, 2026
7c0032b
fix(auth): revoke running work for socket-less owners, and stop trust…
lstein Aug 17, 2026
63fc681
Merge branch 'main' into fix/multiuser-privilege-revocation
JPPhoto Aug 17, 2026
5f796c7
Merge branch 'main' into fix/multiuser-privilege-revocation
lstein Aug 18, 2026
66c2423
Merge branch 'main' into fix/multiuser-privilege-revocation
JPPhoto Aug 18, 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
85 changes: 70 additions & 15 deletions invokeai/app/api/auth_dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""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
Expand All @@ -9,22 +9,79 @@
from invokeai.app.services.auth.token_service import TokenData, verify_token
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 three hold:

- 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.
"""
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 +119,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 +173,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
155 changes: 147 additions & 8 deletions invokeai/app/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
get_token_remaining_seconds,
)
from invokeai.app.services.users.users_common import (
LAST_ADMIN_DETAIL,
SYSTEM_USER_ID,
SYSTEM_USER_PROTECTED_DETAIL,
UserCreateRequest,
UserDTO,
UserUpdateRequest,
Expand All @@ -36,6 +39,31 @@
TOKEN_EXPIRATION_REMEMBER_ME = 7 # 7 days for "remember me" login


def _issue_replacement_token(http_request: Request, response: Response, user: UserDTO, remember_me: bool) -> None:
"""Hand the caller a token minted under the user's *current* revocation epoch.

A password change bumps the epoch, which kills every token issued before it —
including the one that authenticated the request making the change. Without a
replacement, changing a password would sign the caller out of their own session, and
the sliding-window middleware cannot fill the gap: it correctly refuses to refresh a
token whose epoch is already stale. It does leave an already-set header alone, so
what we write here survives.
"""
expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME if remember_me else TOKEN_EXPIRATION_NORMAL)
replacement = create_access_token(
TokenData(
user_id=user.user_id,
email=user.email,
is_admin=user.is_admin,
remember_me=remember_me,
token_epoch=user.token_epoch,
),
expires_delta,
)
response.headers["X-Refreshed-Token"] = replacement
_set_media_cookie(http_request, response, replacement, int(expires_delta.total_seconds()))


class LoginRequest(BaseModel):
"""Request body for user login."""

Expand Down Expand Up @@ -211,6 +239,7 @@ async def login(
email=user.email,
is_admin=user.is_admin,
remember_me=login_request.remember_me,
token_epoch=user.token_epoch,
)
token = create_access_token(token_data, expires_delta)
_set_media_cookie(request, response, token, int(expires_delta.total_seconds()))
Expand Down Expand Up @@ -456,7 +485,7 @@ async def list_users(
List of all real users (system user excluded)
"""
user_service = ApiDependencies.invoker.services.users
return [u for u in user_service.list_users() if u.user_id != "system"]
return [u for u in user_service.list_users() if u.user_id != SYSTEM_USER_ID]


@auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -517,9 +546,15 @@ async def update_user(
user_id: Annotated[str, Path(description="User ID")],
request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")],
current_user: AdminUser,
http_request: Request,
response: Response,
) -> UserDTO:
"""Update a user. Requires admin privileges.

Resetting a password revokes the target's existing sessions. An admin resetting
their own password receives a replacement token in ``X-Refreshed-Token`` so they
are not signed out by their own action.

Args:
user_id: The user ID
request: Fields to update
Expand All @@ -528,22 +563,87 @@ async def update_user(
The updated user

Raises:
HTTPException: 400 if password is weak
HTTPException: 400 if password is weak, if the change would remove the last
administrator, or if it targets the protected system account
HTTPException: 404 if user not found
"""
user_service = ApiDependencies.invoker.services.users
config = ApiDependencies.invoker.services.configuration
before = user_service.get(user_id)
# Match `get_user`/`delete_user`, which 404 for an unknown id. Without this the request
# falls through to the service's `ValueError("User ... not found")` and the route's
# `except ValueError` reports it as a 400, contradicting this endpoint's own contract.
if before is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")

# The system user owns everything migrated from before multiuser support. Deactivating
# it would strand that content: its queue items stop at the dequeue gate, and reads and
# saves against system-owned media raise PermissionError. Promoting it or giving it a
# password is refused for a different reason — see `_assert_system_user_protected`,
# which is the backstop this friendly message fronts.
if user_id == SYSTEM_USER_ID and (
request.is_active is False or request.is_admin is True or request.password is not None
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=SYSTEM_USER_PROTECTED_DETAIL,
)

# Demoting or deactivating the last administrator is irreversible: authorization is
# derived from the database on every request, so the caller loses admin access
# immediately and no authenticated path back exists. It would also drop `has_admin()`
# to zero, which re-opens the unauthenticated `/auth/setup` endpoint to any caller.
# `delete_user` guards the same invariant.
if (
before.is_admin
and before.is_active
and (request.is_admin is False or request.is_active is False)
and user_service.count_admins() <= 1
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=LAST_ADMIN_DETAIL,
)

try:
changes = UserUpdateRequest(
display_name=request.display_name,
password=request.password,
is_admin=request.is_admin,
is_active=request.is_active,
)
return user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking)
updated = user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

# Authorization state changed — notify live connections (open sockets, the
# session processor) so demotion/deactivation takes effect immediately
# instead of persisting until reconnect or token expiry. A password reset bumps
# the epoch without touching is_admin/is_active, and must drop the target's open
# sockets too, so it is part of this condition.
if (
before.is_admin != updated.is_admin
or before.is_active != updated.is_active
or before.token_epoch != updated.token_epoch
):
ApiDependencies.invoker.services.events.emit_user_access_changed(
user_id=updated.user_id,
is_admin=updated.is_admin,
is_active=updated.is_active,
token_epoch=updated.token_epoch,
)

# An admin resetting their *own* password would otherwise lock themselves out: the
# epoch bump kills the token that authenticated this request, and the sliding-window
# middleware correctly refuses to refresh a revoked one. Mirror what /auth/me does.
# An admin who deactivated themselves in the same request gets nothing: the token
# would be rejected on its next use anyway, and setting the media cookie for an
# account this request just disabled advertises a session that does not exist.
if request.password is not None and updated.user_id == current_user.user_id and updated.is_active:
_issue_replacement_token(http_request, response, updated, current_user.remember_me)

return updated


@auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
Expand All @@ -553,46 +653,70 @@ async def delete_user(
"""Delete a user. Requires admin privileges.

Admins can delete any user including other admins, but cannot delete the last
remaining admin.
remaining admin, nor the internal system user.

Args:
user_id: The user ID

Raises:
HTTPException: 400 if attempting to delete the last admin
HTTPException: 400 if attempting to delete the last admin or the system user
HTTPException: 404 if user not found
"""
user_service = ApiDependencies.invoker.services.users
user = user_service.get(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")

# Prevent deleting the last active admin
# The system user owns every board, image, and workflow migrated from before multiuser
# support. Deleting it orphans all of that content: reads and saves against it raise
# PermissionError and its queued items are rejected at dequeue. The last-admin guard
# below does not cover it — the system row is deliberately not an admin.
if user_id == SYSTEM_USER_ID:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=SYSTEM_USER_PROTECTED_DETAIL,
)

# Prevent deleting the last active admin. Same wording as the service backstop: this
# pre-check can lose a race and let the service reject the delete instead, and one
# endpoint should not report one condition two different ways.
if user.is_admin and user.is_active and user_service.count_admins() <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete the last administrator",
detail=LAST_ADMIN_DETAIL,
)

try:
user_service.delete(user_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

# A deleted user must lose live access just like a deactivated one.
ApiDependencies.invoker.services.events.emit_user_access_changed(user_id=user_id, is_admin=False, is_active=False)


@auth_router.patch("/me", response_model=UserDTO)
async def update_current_user(
request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")],
current_user: CurrentUser,
http_request: Request,
response: Response,
) -> UserDTO:
"""Update the current user's own profile.

To change the password, both ``current_password`` and ``new_password`` must
be provided. The current password is verified before the change is applied.

A password change signs out the account's *other* sessions: it bumps the
revocation epoch, invalidating every previously issued token. This response
carries a replacement token in ``X-Refreshed-Token`` so the caller stays
signed in.

Args:
request: Profile fields to update
current_user: The authenticated user
http_request: The HTTP request, used to scope the replacement media cookie
response: The HTTP response, used to return the replacement token

Returns:
The updated user
Expand Down Expand Up @@ -629,8 +753,23 @@ async def update_current_user(
display_name=request.display_name,
password=request.new_password,
)
return user_service.update(
updated = user_service.update(
current_user.user_id, changes, strict_password_checking=config.strict_password_checking
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

if request.new_password is not None:
# Drop the account's other live sockets. They authenticated under the superseded
# epoch and would otherwise keep streaming this user's events even though every
# HTTP request from those sessions is now rejected. The account stays active, so
# the epoch — not is_active — is what marks them.
ApiDependencies.invoker.services.events.emit_user_access_changed(
user_id=updated.user_id,
is_admin=updated.is_admin,
is_active=updated.is_active,
token_epoch=updated.token_epoch,
)
_issue_replacement_token(http_request, response, updated, current_user.remember_me)

return updated
Loading
Loading