From 6aed67d96b22ceb78d3685c753fd1be4bce0d3de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 03:48:35 +0000 Subject: [PATCH 1/6] Initial plan From 8945395b89558e2222244dfa32bc7675b22daea1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 03:56:32 +0000 Subject: [PATCH 2/6] Add Phase 2 authentication service - auth dependencies, router, and service integration Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/auth_dependencies.py | 81 +++++++++ invokeai/app/api/dependencies.py | 3 + invokeai/app/api/routers/auth.py | 181 +++++++++++++++++++ invokeai/app/api_app.py | 3 + invokeai/app/services/invocation_services.py | 3 + 5 files changed, 271 insertions(+) create mode 100644 invokeai/app/api/auth_dependencies.py create mode 100644 invokeai/app/api/routers/auth.py diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py new file mode 100644 index 00000000000..7b1d15e80fe --- /dev/null +++ b/invokeai/app/api/auth_dependencies.py @@ -0,0 +1,81 @@ +"""FastAPI dependencies for authentication.""" + +from typing import Annotated + +from fastapi import 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 + +# HTTP Bearer token security scheme +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], +) -> TokenData: + """Get current authenticated user from Bearer token. + + Args: + credentials: The HTTP authorization credentials containing the Bearer token + + Returns: + TokenData containing user information from the token + + Raises: + HTTPException: If token is missing, invalid, or expired (401 Unauthorized) + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + token_data = verify_token(token) + + if token_data is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired authentication token", + 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) + + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive or does not exist", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return token_data + + +async def require_admin( + current_user: Annotated[TokenData, Depends(get_current_user)], +) -> TokenData: + """Require admin role for the current user. + + Args: + current_user: The current authenticated user's token data + + Returns: + The token data if user is an admin + + Raises: + HTTPException: If user does not have admin privileges (403 Forbidden) + """ + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + return current_user + + +# Type aliases for convenient use in route dependencies +CurrentUser = Annotated[TokenData, Depends(get_current_user)] +AdminUser = Annotated[TokenData, Depends(require_admin)] diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 466a57f804c..71012304327 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -40,6 +40,7 @@ from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage from invokeai.app.services.urls.urls_default import LocalUrlService +from invokeai.app.services.users.users_default import UserService from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( @@ -155,6 +156,7 @@ def initialize( style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) client_state_persistence = ClientStatePersistenceSqlite(db=db) + users = UserService(db=db) services = InvocationServices( board_image_records=board_image_records, @@ -186,6 +188,7 @@ def initialize( style_preset_image_files=style_preset_image_files, workflow_thumbnails=workflow_thumbnails, client_state_persistence=client_state_persistence, + users=users, ) ApiDependencies.invoker = Invoker(services) diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py new file mode 100644 index 00000000000..176e42a1d78 --- /dev/null +++ b/invokeai/app/api/routers/auth.py @@ -0,0 +1,181 @@ +"""Authentication endpoints.""" + +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Body, HTTPException, status +from pydantic import BaseModel, EmailStr, Field + +from invokeai.app.api.auth_dependencies import CurrentUser +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.auth.token_service import TokenData, create_access_token +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO + +auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) + + +class LoginRequest(BaseModel): + """Request body for user login.""" + + email: EmailStr = Field(description="User email address") + password: str = Field(description="User password") + remember_me: bool = Field(default=False, description="Whether to extend session duration") + + +class LoginResponse(BaseModel): + """Response from successful login.""" + + token: str = Field(description="JWT access token") + user: UserDTO = Field(description="User information") + expires_in: int = Field(description="Token expiration time in seconds") + + +class SetupRequest(BaseModel): + """Request body for initial admin setup.""" + + email: EmailStr = Field(description="Admin email address") + display_name: str | None = Field(default=None, description="Admin display name") + password: str = Field(description="Admin password") + + +class SetupResponse(BaseModel): + """Response from successful admin setup.""" + + success: bool = Field(description="Whether setup was successful") + user: UserDTO = Field(description="Created admin user information") + + +class LogoutResponse(BaseModel): + """Response from logout.""" + + success: bool = Field(description="Whether logout was successful") + + +@auth_router.post("/login", response_model=LoginResponse) +async def login( + request: Annotated[LoginRequest, Body(description="Login credentials")], +) -> LoginResponse: + """Authenticate user and return access token. + + Args: + request: Login credentials (email and password) + + Returns: + LoginResponse containing JWT token and user information + + Raises: + HTTPException: 401 if credentials are invalid or user is inactive + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.authenticate(request.email, request.password) + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled") + + # Create token with appropriate expiration + expires_delta = timedelta(days=7 if request.remember_me else 1) + token_data = TokenData( + user_id=user.user_id, + email=user.email, + is_admin=user.is_admin, + ) + token = create_access_token(token_data, expires_delta) + + return LoginResponse( + token=token, + user=user, + expires_in=int(expires_delta.total_seconds()), + ) + + +@auth_router.post("/logout", response_model=LogoutResponse) +async def logout( + current_user: CurrentUser, +) -> LogoutResponse: + """Logout current user. + + Currently a no-op since we use stateless JWT tokens. In the future, this could + be used to invalidate tokens in a server-side session store. + + Args: + current_user: The authenticated user (validates token) + + Returns: + LogoutResponse indicating success + """ + # TODO: Implement token invalidation if using server-side sessions + # For now, this is a no-op since we use stateless JWT tokens + return LogoutResponse(success=True) + + +@auth_router.get("/me", response_model=UserDTO) +async def get_current_user_info( + current_user: CurrentUser, +) -> UserDTO: + """Get current authenticated user's information. + + Args: + current_user: The authenticated user's token data + + Returns: + UserDTO containing user information + + Raises: + HTTPException: 404 if user is not found (should not happen normally) + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(current_user.user_id) + + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + return user + + +@auth_router.post("/setup", response_model=SetupResponse) +async def setup_admin( + request: Annotated[SetupRequest, Body(description="Admin account details")], +) -> SetupResponse: + """Set up initial administrator account. + + This endpoint can only be called once, when no admin user exists. It creates + the first admin user for the system. + + Args: + request: Admin account details (email, display_name, password) + + Returns: + SetupResponse containing the created admin user + + Raises: + HTTPException: 400 if admin already exists or password is weak + """ + user_service = ApiDependencies.invoker.services.users + + # Check if any admin exists + if user_service.has_admin(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Administrator account already configured", + ) + + # Create admin user - this will validate password strength + try: + user_data = UserCreateRequest( + email=request.email, + display_name=request.display_name, + password=request.password, + is_admin=True, + ) + user = user_service.create_admin(user_data) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + return SetupResponse(success=True, user=user) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 335327f532b..bcde15c52eb 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -17,6 +17,7 @@ from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles from invokeai.app.api.routers import ( app_info, + auth, board_images, boards, client_state, @@ -121,6 +122,8 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): # Include all routers +# Authentication router should be first so it's registered before protected routes +app.include_router(auth.auth_router, prefix="/api") app.include_router(utilities.utilities_router, prefix="/api") app.include_router(model_manager.model_manager_router, prefix="/api") app.include_router(download_queue.download_queue_router, prefix="/api") diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 52fb064596d..7a33f49940c 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -36,6 +36,7 @@ from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase + from invokeai.app.services.users.users_base import UserServiceBase from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData @@ -75,6 +76,7 @@ def __init__( style_preset_image_files: "StylePresetImageFileStorageBase", workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", + users: "UserServiceBase", ): self.board_images = board_images self.board_image_records = board_image_records @@ -105,3 +107,4 @@ def __init__( self.style_preset_image_files = style_preset_image_files self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence + self.users = users From 0382b7fe450e80edec521c2d4da3638362592787 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:01:05 +0000 Subject: [PATCH 3/6] Address code review feedback - add token expiration constants and improve documentation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/auth_dependencies.py | 4 ++++ invokeai/app/api/routers/auth.py | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py index 7b1d15e80fe..f5537890b63 100644 --- a/invokeai/app/api/auth_dependencies.py +++ b/invokeai/app/api/auth_dependencies.py @@ -17,6 +17,10 @@ async def get_current_user( ) -> TokenData: """Get current authenticated user from Bearer token. + Note: This function accesses ApiDependencies.invoker.services.users directly, + which is the established pattern in this codebase. The ApiDependencies.invoker + is initialized in the FastAPI lifespan context before any requests are handled. + Args: credentials: The HTTP authorization credentials containing the Bearer token diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index 176e42a1d78..0db4e02af8c 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -13,6 +13,10 @@ auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) +# Token expiration constants (in days) +TOKEN_EXPIRATION_NORMAL = 1 # 1 day for normal login +TOKEN_EXPIRATION_REMEMBER_ME = 7 # 7 days for "remember me" login + class LoginRequest(BaseModel): """Request body for user login.""" @@ -80,7 +84,7 @@ async def login( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled") # Create token with appropriate expiration - expires_delta = timedelta(days=7 if request.remember_me else 1) + expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME if request.remember_me else TOKEN_EXPIRATION_NORMAL) token_data = TokenData( user_id=user.user_id, email=user.email, @@ -101,8 +105,12 @@ async def logout( ) -> LogoutResponse: """Logout current user. - Currently a no-op since we use stateless JWT tokens. In the future, this could - be used to invalidate tokens in a server-side session store. + Currently a no-op since we use stateless JWT tokens. For token invalidation in + future implementations, consider: + - Token blacklist: Store invalidated tokens in Redis/database with expiration + - Token versioning: Add version field to user record, increment on logout + - Short-lived tokens: Use refresh token pattern with token rotation + - Session storage: Track active sessions server-side for revocation Args: current_user: The authenticated user (validates token) @@ -110,7 +118,7 @@ async def logout( Returns: LogoutResponse indicating success """ - # TODO: Implement token invalidation if using server-side sessions + # TODO: Implement token invalidation when server-side session management is added # For now, this is a no-op since we use stateless JWT tokens return LogoutResponse(success=True) From fe5b4475ff28e40e2acf689e42eb950bee18dc51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 03:43:20 +0000 Subject: [PATCH 4/6] Fix email validation to allow special-use domains like .local for testing Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/routers/auth.py | 20 ++++- invokeai/app/services/users/users_common.py | 84 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index 0db4e02af8c..09b6ed5838b 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -4,12 +4,12 @@ from typing import Annotated from fastapi import APIRouter, Body, HTTPException, status -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, Field, field_validator from invokeai.app.api.auth_dependencies import CurrentUser from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.auth.token_service import TokenData, create_access_token -from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, validate_email_with_special_domains auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) @@ -21,10 +21,16 @@ class LoginRequest(BaseModel): """Request body for user login.""" - email: EmailStr = Field(description="User email address") + email: str = Field(description="User email address") password: str = Field(description="User password") remember_me: bool = Field(default=False, description="Whether to extend session duration") + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + class LoginResponse(BaseModel): """Response from successful login.""" @@ -37,10 +43,16 @@ class LoginResponse(BaseModel): class SetupRequest(BaseModel): """Request body for initial admin setup.""" - email: EmailStr = Field(description="Admin email address") + email: str = Field(description="Admin email address") display_name: str | None = Field(default=None, description="Admin display name") password: str = Field(description="Admin password") + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + class SetupResponse(BaseModel): """Response from successful admin setup.""" diff --git a/invokeai/app/services/users/users_common.py b/invokeai/app/services/users/users_common.py index 50d50f6cadd..c13150a3369 100644 --- a/invokeai/app/services/users/users_common.py +++ b/invokeai/app/services/users/users_common.py @@ -2,14 +2,80 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError + + +def validate_email_with_special_domains(email: str) -> str: + """Validate email address, allowing special-use domains like .local for testing. + + This validator first tries standard email validation using email-validator library. + If it fails due to special-use domains (like .local, .test, .localhost), it performs + a basic syntax check instead. This allows development/testing with non-routable domains + while still catching actual typos and malformed emails. + + Args: + email: The email address to validate + + Returns: + The validated email address (lowercased) + + Raises: + PydanticCustomError: If the email format is invalid + """ + try: + # Try standard email validation using email-validator + from email_validator import EmailNotValidError, validate_email + + result = validate_email(email, check_deliverability=False) + return result.normalized + except EmailNotValidError as e: + error_msg = str(e) + + # Check if the error is specifically about special-use/reserved domains or localhost + if ( + "special-use" in error_msg.lower() + or "reserved" in error_msg.lower() + or "should have a period" in error_msg.lower() + ): + # Perform basic email syntax validation + email = email.strip().lower() + + if "@" not in email: + raise PydanticCustomError( + "value_error", + "Email address must contain an @ symbol", + ) + + local_part, domain = email.rsplit("@", 1) + + if not local_part or not domain: + raise PydanticCustomError( + "value_error", + "Email address must have both local and domain parts", + ) + + # Allow localhost and domains with dots + if domain == "localhost" or "." in domain: + return email + + raise PydanticCustomError( + "value_error", + "Email domain must contain a dot or be 'localhost'", + ) + else: + # Re-raise other validation errors + raise PydanticCustomError( + "value_error", + f"Invalid email address: {error_msg}", + ) class UserDTO(BaseModel): """User data transfer object.""" user_id: str = Field(description="Unique user identifier") - email: EmailStr = Field(description="User email address") + email: str = Field(description="User email address") display_name: str | None = Field(default=None, description="Display name") is_admin: bool = Field(default=False, description="Whether user has admin privileges") is_active: bool = Field(default=True, description="Whether user account is active") @@ -17,15 +83,27 @@ class UserDTO(BaseModel): updated_at: datetime = Field(description="When the user was last updated") last_login_at: datetime | None = Field(default=None, description="When user last logged in") + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + class UserCreateRequest(BaseModel): """Request to create a new user.""" - email: EmailStr = Field(description="User email address") + email: str = Field(description="User email address") display_name: str | None = Field(default=None, description="Display name") password: str = Field(description="User password") is_admin: bool = Field(default=False, description="Whether user should have admin privileges") + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + class UserUpdateRequest(BaseModel): """Request to update a user.""" From 5dffaeb0035c9c43b417ee11ee6e0efa3eb8f153 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 03:54:06 +0000 Subject: [PATCH 5/6] Fix system user to not be admin - allows /auth/setup to work on fresh database Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../shared/sqlite_migrator/migrations/migration_25.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py index 527e4ec2c84..c29eff876c2 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py @@ -198,10 +198,15 @@ def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None: cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);") def _create_system_user(self, cursor: sqlite3.Cursor) -> None: - """Create system user for backward compatibility.""" + """Create system user for backward compatibility. + + The system user is NOT an admin - it's just used to own existing data + from before multi-user support was added. Real admin users should be + created through the /auth/setup endpoint. + """ cursor.execute(""" INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active) - VALUES ('system', 'system@system.invokeai', 'System', '', TRUE, TRUE); + VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE); """) From 2682ee73180f2fb6352440999441c4a3627c80fc Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 7 Jan 2026 23:40:20 -0500 Subject: [PATCH 6/6] chore: typegen --- .../frontend/web/src/services/api/schema.ts | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 1f0464d1cc4..2a076a0d2af 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1,4 +1,127 @@ export type paths = { + "/api/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Login + * @description Authenticate user and return access token. + * + * Args: + * request: Login credentials (email and password) + * + * Returns: + * LoginResponse containing JWT token and user information + * + * Raises: + * HTTPException: 401 if credentials are invalid or user is inactive + */ + post: operations["login_api_v1_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Logout + * @description Logout current user. + * + * Currently a no-op since we use stateless JWT tokens. For token invalidation in + * future implementations, consider: + * - Token blacklist: Store invalidated tokens in Redis/database with expiration + * - Token versioning: Add version field to user record, increment on logout + * - Short-lived tokens: Use refresh token pattern with token rotation + * - Session storage: Track active sessions server-side for revocation + * + * Args: + * current_user: The authenticated user (validates token) + * + * Returns: + * LogoutResponse indicating success + */ + post: operations["logout_api_v1_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Current User Info + * @description Get current authenticated user's information. + * + * Args: + * current_user: The authenticated user's token data + * + * Returns: + * UserDTO containing user information + * + * Raises: + * HTTPException: 404 if user is not found (should not happen normally) + */ + get: operations["get_current_user_info_api_v1_auth_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Setup Admin + * @description Set up initial administrator account. + * + * This endpoint can only be called once, when no admin user exists. It creates + * the first admin user for the system. + * + * Args: + * request: Admin account details (email, display_name, password) + * + * Returns: + * SetupResponse containing the created admin user + * + * Raises: + * HTTPException: 400 if admin already exists or password is weak + */ + post: operations["setup_admin_api_v1_auth_setup_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/utilities/dynamicprompts": { parameters: { query?: never; @@ -15653,6 +15776,57 @@ export type components = { * @enum {integer} */ LogLevel: 0 | 10 | 20 | 30 | 40 | 50; + /** + * LoginRequest + * @description Request body for user login. + */ + LoginRequest: { + /** + * Email + * @description User email address + */ + email: string; + /** + * Password + * @description User password + */ + password: string; + /** + * Remember Me + * @description Whether to extend session duration + * @default false + */ + remember_me?: boolean; + }; + /** + * LoginResponse + * @description Response from successful login. + */ + LoginResponse: { + /** + * Token + * @description JWT access token + */ + token: string; + /** @description User information */ + user: components["schemas"]["UserDTO"]; + /** + * Expires In + * @description Token expiration time in seconds + */ + expires_in: number; + }; + /** + * LogoutResponse + * @description Response from logout. + */ + LogoutResponse: { + /** + * Success + * @description Whether logout was successful + */ + success: boolean; + }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -22296,6 +22470,40 @@ export type components = { */ total: number; }; + /** + * SetupRequest + * @description Request body for initial admin setup. + */ + SetupRequest: { + /** + * Email + * @description Admin email address + */ + email: string; + /** + * Display Name + * @description Admin display name + */ + display_name?: string | null; + /** + * Password + * @description Admin password + */ + password: string; + }; + /** + * SetupResponse + * @description Response from successful admin setup. + */ + SetupResponse: { + /** + * Success + * @description Whether setup was successful + */ + success: boolean; + /** @description Created admin user information */ + user: components["schemas"]["UserDTO"]; + }; /** * Show Image * @description Displays a provided image using the OS image viewer, and passes it forward in the pipeline. @@ -24618,6 +24826,56 @@ export type components = { */ unstarred_images: string[]; }; + /** + * UserDTO + * @description User data transfer object. + */ + UserDTO: { + /** + * User Id + * @description Unique user identifier + */ + user_id: string; + /** + * Email + * @description User email address + */ + email: string; + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Is Admin + * @description Whether user has admin privileges + * @default false + */ + is_admin?: boolean; + /** + * Is Active + * @description Whether user account is active + * @default true + */ + is_active?: boolean; + /** + * Created At + * Format: date-time + * @description When the user was created + */ + created_at: string; + /** + * Updated At + * Format: date-time + * @description When the user was last updated + */ + updated_at: string; + /** + * Last Login At + * @description When user last logged in + */ + last_login_at?: string | null; + }; /** VAEField */ VAEField: { /** @description Info to load vae submodel */ @@ -26155,6 +26413,112 @@ export type components = { }; export type $defs = Record; export interface operations { + login_api_v1_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_api_v1_auth_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogoutResponse"]; + }; + }; + }; + }; + get_current_user_info_api_v1_auth_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + }; + }; + setup_admin_api_v1_auth_setup_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetupRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetupResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; parse_dynamicprompts: { parameters: { query?: never;