vs-security
Authentication and authorization library for FastAPI services in the Viveka Sutra platform. It provides a provider-based authentication model, flexible session generation, JWT token management, and route-level guards — all wired together without boilerplate.
Installation
pip install vs-security
With FastAPI guard support (recommended):
pip install "vs-security[fastapi]"
Dependencies: pyjwt, bcrypt, pydantic, vs-common
Architecture Overview
The library is built around three independent concerns that compose together:
Authentication — who are you? A VsAuthProvider receives credentials (username/password, OAuth token, API key — anything) and returns a VsAuthContext confirming the user's identity and roles.
Session generation — what do we give back? A VsSessionGenerator receives the authenticated VsAuthContext and produces a VsSession. By default this mints a JWT pair. You can swap in any other mechanism — Redis sessions, opaque tokens, signed cookies — without changing anything else.
Authorization — are you allowed? VsSecurity is a FastAPI dependency that verifies the incoming token on every protected request and makes VsAuthContext available inside the handler.
┌──────────────────────────────────────────────────────────┐
│ LOGIN FLOW │
│ │
│ credentials ──► VsAuthProvider ──► VsAuthContext │
│ │ │
│ VsSessionGenerator │
│ │ │
│ VsSession │
│ (access_token etc.) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
│ │
│ Authorization: Bearer <token> │
│ │ │
│ VsSecurity (FastAPI dependency/guard) │
│ │ verifies token, checks roles │
│ ▼ │
│ VsAuthContext ◄── get_auth_context() │
│ available anywhere inside the handler │
└──────────────────────────────────────────────────────────┘
The key components and their relationships:
| Class | Role | Extend? |
|---|---|---|
VsAuthManager |
Coordinator — routes credentials to the right provider and calls the session generator | No |
VsAuthProvider |
Verifies credentials, returns VsAuthContext |
Yes — one per auth mechanism |
VsSessionGenerator |
Converts VsAuthContext into a VsSession |
Yes — to change what is returned after login |
VsUserLoader |
Loads a user from your database for username/password auth | Yes — required for VsUsernamePasswordAuthProvider |
VsJWTProvider |
Mints, verifies, refreshes, and revokes JWT tokens. Also implements VsSessionGenerator — pass it directly as the session generator |
No — use as-is |
VsSecurity |
FastAPI dependency that guards routes | No — configure via VsSecurityFactory |
Quick Start — Complete Example
This shows a fully wired JWT-based auth setup from startup to protected route.
Step 1 — Implement your user loader
VsUserLoader is the bridge between your database and the auth system. It is the only class you must implement for username/password auth.
# app/auth/my_user_loader.py
from vs_security.auth.vs_user_loader import VsUserLoader
from vs_security.schema.vs_auth_context import VsAuthContext
from app.repo.user_identity_repo import UserIdentityRepo
class MyUserLoader(VsUserLoader):
def __init__(self):
self._repo = UserIdentityRepo()
async def load(self, username: str):
"""
Look up a user by username.
Return (VsAuthContext, stored_credential) if found, None if not.
stored_credential is the hashed password string from your database.
"""
identity = await self._repo.find_by_email(username)
if not identity:
return None
context = VsAuthContext(
user_id=identity.user_id,
username=identity.email,
roles=identity.roles, # e.g. ["user", "admin"]
provider="email",
)
return context, identity.hashed_password # hashed_password is a bcrypt hash
# Optional — override only if you use a hashing scheme other than bcrypt
def verify_password(self, plain: str, stored: str) -> bool:
import bcrypt
return bcrypt.checkpw(plain.encode(), stored.encode())
Step 2 — Wire everything at startup
Providers are registered on the VsAuthManager class (static registry) before any instance is created. The instance only needs the session generator.
# app/startup.py
from vs_common.config.vs_base_config import VsBaseConfig
from vs_security.auth.vs_auth_manager import VsAuthManager
from vs_security.auth.vs_jwt_provider import VsJWTProvider
from vs_security.auth.vs_username_password_provider import VsUsernamePasswordAuthProvider
from vs_security.guard.vs_security_factory import VsSecurityFactory
from app.auth.my_user_loader import MyUserLoader
config = VsBaseConfig("config.ini")
# 1. Register auth providers on the class — this happens once at module load
VsAuthManager.register(
"username_password",
VsUsernamePasswordAuthProvider(user_loader=MyUserLoader())
)
# 2. Create the JWT provider — reads secret key and expiry from config
# VsJWTProvider implements VsSessionGenerator, so pass it directly
jwt_provider = VsJWTProvider(config=config)
# 3. Initialize the singleton — call once at startup
auth_manager = VsAuthManager.init(session_generator=jwt_provider)
# 4. Initialize the guard factory so protected routes can verify tokens
VsSecurityFactory.init(
secret_key=config.get("auth.secret_key"),
algorithm="HS256",
)
Step 3 — Add a login endpoint
authenticate() returns a VsSession. When using VsJWTProvider as the session generator, it contains access_token and refresh_token.
# app/controller/auth_controller.py
from pydantic import BaseModel
from fastapi import HTTPException
from vs_server.decorator.vs_controller_decorator import controller, post
from vs_security.auth.vs_auth_manager import VsAuthManager
from vs_security.schema.vs_credentials import VsUsernamePasswordCredentials
from vs_security.error.vs_auth_error import VsAuthenticationError
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
@controller("/auth")
class AuthController:
def __init__(self):
self._auth = VsAuthManager.get_instance()
@post("/login", response_model=LoginResponse, status_code=200)
async def login(self, body: LoginRequest):
try:
session = await self._auth.authenticate(
"username_password",
VsUsernamePasswordCredentials(
username=body.username,
password=body.password,
),
)
return LoginResponse(
access_token=session.access_token,
refresh_token=session.refresh_token,
)
except VsAuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
Step 4 — Protect routes
Apply VsSecurityFactory.get() as a guard to any controller or individual route. The guard automatically extracts the Authorization: Bearer <token> header, verifies it, and makes the user context available.
# app/controller/user_controller.py
from vs_server.decorator.vs_controller_decorator import controller, get
from vs_security.guard.vs_security_factory import VsSecurityFactory
from vs_security.guard.vs_security import get_auth_context
@controller("/users", guards=[VsSecurityFactory.get()])
class UserController:
@get("/me")
async def get_me(self):
context = get_auth_context() # populated by the guard
return {
"user_id": str(context.user_id),
"username": context.username,
"roles": context.roles,
}
@get("/admin-only", guards=[VsSecurityFactory.with_roles(["admin"])])
async def admin_only(self):
return {"message": "you have admin access"}
Configuration (config.ini)
[auth]
secret_key = your-very-secret-key-here
algorithm = HS256
access_expiry_minutes = 15
refresh_expiry_days = 7
| Key | Default | Description |
|---|---|---|
auth.secret_key |
required | JWT signing secret — keep this out of source control |
auth.algorithm |
HS256 |
JWT signing algorithm |
auth.access_expiry_minutes |
15 |
How long an access token is valid |
auth.refresh_expiry_days |
7 |
How long a refresh token is valid |
VsAuthManager — In Depth
VsAuthManager is the central coordinator for the login flow. It does two things:
- Routes incoming credentials to the correct
VsAuthProviderbased on the provider name string. - Calls
VsSessionGenerator.generate()on the resultingVsAuthContextto produce aVsSession.
The provider registry is a class-level dict shared across all instances. This means you register providers once at startup (before creating any instance), and every instance sees them.
# 1. Register providers — on the class, before init
VsAuthManager.register("username_password", VsUsernamePasswordAuthProvider(...))
VsAuthManager.register("google", GoogleAuthProvider(...))
VsAuthManager.register("api_key", ApiKeyAuthProvider(...))
# 2. Initialize the singleton once at startup
VsAuthManager.init(session_generator=jwt_provider)
# 3. Retrieve the instance anywhere in the app
auth_manager = VsAuthManager.get_instance()
# 4. Authenticate at login time
session = await auth_manager.authenticate(
"username_password", # which provider to use
VsUsernamePasswordCredentials(...), # credentials for that provider
)
The provider name is arbitrary — it just has to match between register() and authenticate(). Using a string (not a type) means a single manager can support multiple providers simultaneously. A mobile app might call authenticate("google", ...) while a web form calls authenticate("username_password", ...) — same manager, different providers.
VsAuthProvider — In Depth
VsAuthProvider is the place where credential verification happens. Each provider handles one credential type. It receives raw credentials and returns a VsAuthContext or raises a VsAuthenticationError.
from abc import ABC, abstractmethod
from vs_security.schema.vs_auth_context import VsAuthContext
from vs_security.schema.vs_credentials import VsCredentials
class VsAuthProvider(ABC):
@abstractmethod
async def authenticate(self, credentials: VsCredentials) -> VsAuthContext: ...
Rules:
- Return
VsAuthContexton success. - Raise
VsAuthenticationError(or a subclass) on failure — never returnNone. - The provider does not know or care about sessions or tokens. Its only job is to say "yes, this is user X with roles Y".
Built-in provider: VsUsernamePasswordAuthProvider handles email/password login via VsUserLoader.
Custom provider example — API key:
from vs_security.auth.vs_auth_provider import VsAuthProvider
from vs_security.schema.vs_auth_context import VsAuthContext
from vs_security.schema.vs_credentials import VsCredentials
from vs_security.error.vs_auth_error import VsInvalidCredentialsError
from pydantic import BaseModel
class ApiKeyCredentials(VsCredentials):
api_key: str
class ApiKeyAuthProvider(VsAuthProvider):
def __init__(self, key_repo):
self._repo = key_repo
async def authenticate(self, credentials: VsCredentials) -> VsAuthContext:
if not isinstance(credentials, ApiKeyCredentials):
raise TypeError("Expected ApiKeyCredentials")
record = await self._repo.find_by_key(credentials.api_key)
if not record or not record.is_active:
raise VsInvalidCredentialsError()
return VsAuthContext(
user_id=record.owner_id,
username=record.owner_email,
roles=record.roles,
provider="api_key",
)
# Register at startup
VsAuthManager.register("api_key", ApiKeyAuthProvider(key_repo=ApiKeyRepo()))
# Login
session = await auth_manager.authenticate(
"api_key",
ApiKeyCredentials(api_key=request.headers["X-Api-Key"]),
)
VsUserLoader — In Depth
VsUserLoader is used exclusively by VsUsernamePasswordAuthProvider. It separates the database concern (finding the user) from the authentication concern (verifying the password).
class VsUserLoader(ABC):
@abstractmethod
async def load(self, username: str) -> Optional[Tuple[VsAuthContext, str]]:
"""Returns (VsAuthContext, stored_credential) or None if user not found."""
...
def verify_password(self, plain: str, stored: str) -> bool:
"""Defaults to bcrypt. Override for argon2, scrypt, or any other scheme."""
import bcrypt
return bcrypt.checkpw(plain.encode(), stored.encode())
load() — receives the username (email, phone, or whatever your system uses) and returns a tuple of (VsAuthContext, stored_credential). The stored_credential is the hashed password string exactly as stored in your database. Return None if the user is not found — the provider will raise VsInvalidCredentialsError automatically.
verify_password() — receives the raw plaintext password the user submitted and the stored credential from load(). The default implementation uses bcrypt. Override this if you hash passwords with a different algorithm.
# argon2 example
def verify_password(self, plain: str, stored: str) -> bool:
from argon2 import PasswordHasher
ph = PasswordHasher()
try:
return ph.verify(stored, plain)
except Exception:
return False
VsAuthContext can be a subclass. If your system has extra fields (tenant ID, subscription plan, etc.) you want available inside protected routes, extend VsAuthContext:
from vs_security.schema.vs_auth_context import VsAuthContext
class AppAuthContext(VsAuthContext):
tenant_id: str
plan: str
# In your loader's load() method:
return AppAuthContext(
user_id=user.id,
username=user.email,
roles=user.roles,
provider="email",
tenant_id=user.tenant_id,
plan=user.subscription_plan,
), user.hashed_password
# Inside a protected handler:
context = get_auth_context() # returns the AppAuthContext you set
print(context.tenant_id) # works — it's the same object
VsSessionGenerator — In Depth
VsSessionGenerator defines what the login response looks like. After VsAuthProvider confirms identity, VsAuthManager hands the VsAuthContext to the session generator to produce a VsSession.
class VsSessionGenerator(ABC):
@abstractmethod
async def generate(self, context: VsAuthContext) -> VsSession: ...
VsSession holds the result:
class VsSession(BaseModel):
access_token: Optional[str] = None # JWT access token, opaque token, etc.
refresh_token: Optional[str] = None # JWT refresh token, session renewal token, etc.
data: Dict[str, Any] = {} # any extra fields your implementation needs
Built-in — VsJWTProvider:
VsJWTProvider implements VsSessionGenerator directly — its generate() method mints a JWT access/refresh pair. Pass it as the session generator without any wrapper.
jwt_provider = VsJWTProvider(config=config, token_store=token_store)
auth_manager = VsAuthManager(session_generator=jwt_provider)
# After authenticate():
# session.access_token → signed JWT, e.g. "eyJhbGciOi..."
# session.refresh_token → signed JWT for token renewal
# session.data → {} (empty for JWT)
Custom — Redis session store:
If you want server-side sessions instead of stateless JWTs, implement VsSessionGenerator:
from vs_security.auth.vs_session_generator import VsSessionGenerator
from vs_security.schema.vs_auth_context import VsAuthContext
from vs_security.schema.vs_session import VsSession
import uuid
class RedisSessionGenerator(VsSessionGenerator):
def __init__(self, redis_client, ttl_seconds: int = 3600):
self._redis = redis_client
self._ttl = ttl_seconds
async def generate(self, context: VsAuthContext) -> VsSession:
session_id = str(uuid.uuid4())
await self._redis.setex(
f"session:{session_id}",
self._ttl,
context.model_dump_json(),
)
return VsSession(data={"session_id": session_id, "expires_in": self._ttl})
# Wire it in
auth_manager = VsAuthManager(session_generator=RedisSessionGenerator(redis_client=redis))
# After authenticate():
# session.access_token → None
# session.refresh_token → None
# session.data → {"session_id": "abc-123", "expires_in": 3600}
Custom — opaque token:
class OpaqueTokenGenerator(VsSessionGenerator):
def __init__(self, token_store):
self._store = token_store
async def generate(self, context: VsAuthContext) -> VsSession:
import secrets
token = secrets.token_urlsafe(32)
await self._store.save(token, context)
return VsSession(access_token=token)
VsJWTProvider — Token Lifecycle
VsJWTProvider handles everything JWT-related: minting tokens, verifying them, refreshing them, and revoking them. It reads its configuration from config.ini.
jwt_provider = VsJWTProvider(config=config, token_store=token_store)
token_store is optional. Without it, refresh tokens are stateless (cannot be revoked). With it, refresh tokens are tracked and can be revoked individually or all at once.
Refresh a token:
@post("/auth/refresh")
async def refresh(body: RefreshRequest):
try:
pair = await jwt_provider.refresh_token(body.refresh_token)
return {"access_token": pair.access_token, "refresh_token": pair.refresh_token}
except VsAuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
Revoke tokens (logout):
@post("/auth/logout")
async def logout(body: LogoutRequest):
context = get_auth_context()
await jwt_provider.revoke_token(context.user_id, body.refresh_token)
return {"message": "logged out"}
@post("/auth/logout-all")
async def logout_all():
context = get_auth_context()
await jwt_provider.revoke_all_tokens(context.user_id)
return {"message": "logged out from all devices"}
Implementing VsTokenStore:
from vs_security.auth.vs_token_store import VsTokenStore
from uuid import UUID
class RedisTokenStore(VsTokenStore):
def __init__(self, redis_client):
self._redis = redis_client
async def save(self, user_id: UUID, refresh_token: str) -> None:
await self._redis.sadd(f"tokens:{user_id}", refresh_token)
async def get_all(self, user_id: UUID) -> list[str]:
return list(await self._redis.smembers(f"tokens:{user_id}"))
async def delete(self, user_id: UUID, refresh_token: str) -> None:
await self._redis.srem(f"tokens:{user_id}", refresh_token)
async def delete_all(self, user_id: UUID) -> None:
await self._redis.delete(f"tokens:{user_id}")
VsSecurity — Route Guards
VsSecurity is a FastAPI callable dependency. On every request it:
- Reads the
Authorization: Bearer <token>header. - Verifies the token signature and expiry via
VsJWTProvider. - Checks that the user has the required roles (if any).
- Stores
VsAuthContextin a context variable soget_auth_context()works anywhere in the call stack.
Guards are configured via VsSecurityFactory:
# Protect all routes in a controller — any authenticated user
@controller("/users", guards=[VsSecurityFactory.get()])
class UserController: ...
# Protect specific routes — only users with "admin" role
@controller("/admin")
class AdminController:
@get("/report", guards=[VsSecurityFactory.with_roles(["admin"])])
async def get_report(self): ...
# Multiple roles — user must have at least one of them
@get("/dashboard", guards=[VsSecurityFactory.with_roles(["admin", "manager"])])
async def dashboard(): ...
VsSecurityFactory.init() must be called once at startup before any guards are used:
VsSecurityFactory.init(
secret_key=config.get("auth.secret_key"),
algorithm="HS256", # must match what VsJWTProvider uses
)
Accessing the auth context inside a handler:
from vs_security.guard.vs_security import get_auth_context
@get("/me")
async def get_me(self):
context = get_auth_context()
return {
"user_id": str(context.user_id),
"username": context.username,
"roles": context.roles,
"provider": context.provider,
}
get_auth_context() uses Python's contextvars — it is safe in async code and always returns the context for the current request, even under concurrent load.
VsAuthContext Reference
VsAuthContext is the canonical representation of an authenticated user. It is available inside any guarded handler via get_auth_context().
context.user_id # UUID — unique user identifier
context.username # str — display name or email
context.roles # List[str] — e.g. ["user", "admin"]
context.provider # str — which provider authenticated the user ("email", "google", etc.)
context.has_role("admin") # True if "admin" is in roles
context.has_any_role("admin", "moderator") # True if any of these roles match
context.has_all_roles("admin", "moderator") # True if all roles match
To attach custom fields, extend VsAuthContext and return the subclass from your VsUserLoader.load():
class AppAuthContext(VsAuthContext):
tenant_id: str
# In load():
return AppAuthContext(..., tenant_id=user.tenant_id), user.hashed_password
# In a handler:
context: AppAuthContext = get_auth_context()
print(context.tenant_id)
Error Reference
| Exception | HTTP Status | Raised By | When |
|---|---|---|---|
VsInvalidCredentialsError |
401 | VsUsernamePasswordAuthProvider |
Wrong username or password |
VsAuthenticationError |
401 | Any provider | Generic auth failure — base class for all auth errors |
VsTokenExpiredError |
401 | VsJWTProvider |
JWT access or refresh token has expired |
VsTokenRevokedError |
401 | VsJWTProvider |
Refresh token was explicitly revoked |
VsInsufficientRolesError |
403 | VsSecurity |
User authenticated but lacks required roles |
VsSecurity catches VsAuthenticationError automatically and converts it to an HTTP 401 response. You only need to catch these exceptions in your login and refresh endpoints.
@post("/auth/login")
async def login(body: LoginRequest):
try:
session = await auth_manager.authenticate(...)
return {"access_token": session.access_token}
except VsInvalidCredentialsError:
raise HTTPException(status_code=401, detail="Invalid username or password")
except VsAuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
Metadata
Release files for vs-security 0.1.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| vs_security-0.1.5.tar.gz | 20.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vs_security-0.1.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 37.7 kB
Release files / vs_security-0.1.5.tar.gz
| Download URL | vs_security-0.1.5.tar.gz |
|---|---|
| Size | 20.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
46b5e856f2f8c3cd8a999e3f2b4ee7bfc45729d8fdf632a69d9e393ca087c033
|
|
BLAKE2b-256 checksum How to use checksums |
4b8f1f17280d97f24074fd86f095f196ef11443e970782aa33ad7e6a61431842
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Release files / vs_security-0.1.5-py3-none-any.whl
| Download URL | vs_security-0.1.5-py3-none-any.whl |
|---|---|
| Size | 17.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0df28f37aa9efef0a7a5dcf0d116e7080171b51fd7b9dd29f129671488359988
|
|
BLAKE2b-256 checksum How to use checksums |
c745f39c675a92216a6c78e2769646d50ff80345bdf14288bdc9680934228347
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Security for vs-security 0.1.5
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.