vs-security
Authentication and authorization library for FastAPI services in the Viveka Sutra platform. It provides a provider-based authentication model, generic session handling, 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.
Session — what do we give back, and how do we verify it? A VsSession[T] has two responsibilities: generate() produces a session of type T after login, and validate() verifies an incoming token and returns the VsAuthContext. Both live in one class so the generate and validate logic stays together. VsJWTProvider is the built-in implementation — it returns VsTokenPair.
Authorization — are you allowed? VsSecurity is a FastAPI dependency that calls validate() on every protected request and makes VsAuthContext available inside the handler.
┌──────────────────────────────────────────────────────────┐
│ LOGIN FLOW │
│ │
│ credentials ──► VsAuthProvider ──► VsAuthContext │
│ │ │
│ VsSession.generate() │
│ │ │
│ T │
│ (VsTokenPair, etc.) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
│ │
│ Authorization: Bearer <token> │
│ │ │
│ VsSecurity (FastAPI dependency/guard) │
│ │ calls VsSession.validate(token) │
│ ▼ │
│ VsAuthContext ◄── get_auth_context() │
│ available anywhere inside the handler │
└──────────────────────────────────────────────────────────┘
| Class | Role | Extend? |
|---|---|---|
VsAuthManager |
Coordinator — routes credentials to the right provider and calls the session | No |
VsAuthProvider |
Verifies credentials, returns VsAuthContext |
Yes — one per auth mechanism |
VsSession[T] |
Abstract base — implement generate() and validate() to define what a session looks like and how it is verified |
Yes — to change the session format |
VsUserLoader |
Loads a user from your database for username/password auth | Yes — required for VsUsernamePasswordAuthProvider |
VsJWTProvider |
Built-in VsSession[VsTokenPair] — mints, verifies, refreshes, and revokes JWTs |
No — use as-is |
VsSecurity |
FastAPI dependency that guards routes | No — use VsSecurity.from_manager() |
Quick Start — Complete Example
Step 1 — Implement your user loader
from vs_security.auth.vs_user_loader import VsUserLoader
from vs_security.schema.vs_auth_context import VsAuthContext
class MyUserLoader(VsUserLoader):
def __init__(self):
self._repo = UserIdentityRepo()
async def load(self, username: str):
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,
provider="email",
)
return context, identity.hashed_password
def verify_password(self, plain: str, stored: str) -> bool:
import bcrypt
return bcrypt.checkpw(plain.encode(), stored.encode())
Step 2 — Wire everything at startup
One VsJWTProvider instance is shared between VsAuthManager (for login) and VsSecurity (for request validation). There is no separate factory to initialize.
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
jwt_provider = VsJWTProvider(config=config)
VsAuthManager.init(session=jwt_provider)
VsAuthManager.register("username_password", VsUsernamePasswordAuthProvider(
user_loader=MyUserLoader()
))
# Controllers are imported after this point so VsSecurity.from_manager() resolves correctly
from app.controller.auth_controller import AuthController
from app.controller.user_controller import UserController
Step 3 — Add a login endpoint
authenticate() returns whatever T your VsSession produces. With VsJWTProvider, that is VsTokenPair.
from vs_security.auth.vs_auth_manager import VsAuthManager
from vs_security.schema.vs_credentials import VsUsernamePasswordCredentials
from vs_security.schema.vs_token_pair import VsTokenPair
from vs_security.error.vs_auth_error import VsAuthenticationError
from vs_server.decorator.vs_controller_decorator import controller, post
@controller("/auth")
class AuthController:
@post("/login", response_model=VsTokenPair)
async def login(self, body: LoginRequest):
try:
return await VsAuthManager.get_instance().authenticate(
"username_password",
VsUsernamePasswordCredentials(
username=body.username,
password=body.password,
),
)
except VsAuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
Step 4 — Protect routes
from vs_security.guard.vs_security import VsSecurity, get_auth_context
from vs_server.decorator.vs_controller_decorator import controller, get
@controller("/users", guards=[VsSecurity.from_manager()])
class UserController:
@get("/me")
async def get_me(self):
context = get_auth_context()
return {
"user_id": str(context.user_id),
"username": context.username,
"roles": context.roles,
}
@get("/admin-only", guards=[VsSecurity.from_manager(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 |
auth.algorithm |
HS256 |
JWT signing algorithm |
auth.access_expiry_minutes |
15 |
Access token lifetime |
auth.refresh_expiry_days |
7 |
Refresh token lifetime |
VsAuthManager — In Depth
VsAuthManager is a singleton coordinator. It routes credentials to the correct provider and hands the resulting VsAuthContext to VsSession.generate().
# Initialize once at startup — pass a VsSession[T] implementation
VsAuthManager.init(session=VsJWTProvider(config=config))
# Register providers — call before importing controllers
VsAuthManager.register("username_password", VsUsernamePasswordAuthProvider(...))
VsAuthManager.register("google", GoogleAuthProvider(...))
VsAuthManager.register("api_key", ApiKeyAuthProvider(...))
# Retrieve anywhere in the app
auth_manager = VsAuthManager.get_instance()
# At login time
token_pair = await auth_manager.authenticate(
"username_password",
VsUsernamePasswordCredentials(username=..., password=...),
)
VsAuthProvider — In Depth
VsAuthProvider verifies credentials and returns who the user is. It knows nothing about sessions or tokens.
class VsAuthProvider(ABC):
@abstractmethod
async def authenticate(self, credentials: VsCredentials) -> VsAuthContext: ...
Rules:
- Return
VsAuthContexton success. - Raise
VsAuthenticationError(or a subclass) on failure — never returnNone.
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
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,
roles=record.roles,
provider="api_key",
)
VsAuthManager.register("api_key", ApiKeyAuthProvider(key_repo=ApiKeyRepo()))
VsSession — In Depth
VsSession[T] is the abstract base for both session generation (login) and token validation (requests). Implement it when you want a session format other than JWT.
class VsSession(ABC, Generic[T]):
@abstractmethod
async def generate(self, context: VsAuthContext) -> T:
"""Called after login — produce a session from the authenticated context."""
...
@abstractmethod
async def validate(self, token: str) -> VsAuthContext:
"""Called on each request — verify the token and return the auth context."""
...
Custom example — Redis session store:
from vs_security.schema.vs_session import VsSession
from vs_security.schema.vs_auth_context import VsAuthContext
from pydantic import BaseModel
import uuid
class RedisSessionData(BaseModel):
session_id: str
expires_in: int
class RedisSession(VsSession[RedisSessionData]):
def __init__(self, redis_client, ttl: int = 3600):
self._redis = redis_client
self._ttl = ttl
async def generate(self, context: VsAuthContext) -> RedisSessionData:
session_id = str(uuid.uuid4())
await self._redis.setex(f"session:{session_id}", self._ttl, context.model_dump_json())
return RedisSessionData(session_id=session_id, expires_in=self._ttl)
async def validate(self, token: str) -> VsAuthContext:
data = await self._redis.get(f"session:{token}")
if not data:
raise VsAuthenticationError("Session not found or expired")
return VsAuthContext.model_validate_json(data)
# Wire it in
VsAuthManager.init(session=RedisSession(redis_client=redis))
VsJWTProvider — Token Lifecycle
VsJWTProvider is the built-in VsSession[VsTokenPair]. It handles minting, verifying, refreshing, and revoking JWTs.
jwt_provider = VsJWTProvider(config=config, token_store=token_store)
VsAuthManager.init(session=jwt_provider)
token_store is optional. Without it, refresh tokens are stateless (cannot be revoked). With it, they are tracked.
Refresh a token:
@post("/auth/refresh")
async def refresh(body: RefreshRequest):
try:
pair = await jwt_provider.refresh_token(body.refresh_token)
return pair
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)
@post("/auth/logout-all")
async def logout_all():
context = get_auth_context()
await jwt_provider.revoke_all_tokens(context.user_id)
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. - Calls
VsSession.validate(token)to verify and decode it. - Checks that the user has the required roles (if any).
- Stores
VsAuthContextin a context variable soget_auth_context()works anywhere in the call stack.
Use VsSecurity.from_manager() to create a guard backed by the initialized VsAuthManager session:
from vs_security.guard.vs_security import VsSecurity
# Any authenticated user
@controller("/users", guards=[VsSecurity.from_manager()])
class UserController: ...
# Only users with "admin" role
@get("/report", guards=[VsSecurity.from_manager(roles=["admin"])])
async def get_report(self): ...
# Multiple roles — user must have at least one
@get("/dashboard", guards=[VsSecurity.from_manager(roles=["admin", "manager"])])
async def dashboard(): ...
Important: Controllers must be imported after VsAuthManager.init() is called, since VsSecurity.from_manager() resolves the session at decoration time.
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, # Optional[str]
"roles": context.roles,
"provider": context.provider, # Optional[str]
}
get_auth_context() uses Python's contextvars — safe in async code and always returns the context for the current request.
VsAuthContext Reference
VsAuthContext represents the authenticated user. Only user_id is required — all other fields are optional since different auth providers may not have them.
context.user_id # UUID — required, unique user identifier
context.username # Optional[str] — display name or email
context.roles # List[str] — e.g. ["user", "admin"], defaults to []
context.provider # Optional[str] — which provider authenticated the user
context.has_role("admin")
context.has_any_role("admin", "moderator")
context.has_all_roles("admin", "moderator")
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 or session | Generic auth failure — base class for all auth errors |
VsTokenExpiredError |
401 | VsJWTProvider |
JWT 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 HTTP 401. Only catch these in your login and refresh endpoints:
@post("/auth/login")
async def login(body: LoginRequest):
try:
return await VsAuthManager.get_instance().authenticate(...)
except VsInvalidCredentialsError:
raise HTTPException(status_code=401, detail="Invalid credentials")
except VsAuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
Metadata
Release files for vs-security 0.1.7
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.7.tar.gz | 16.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vs_security-0.1.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 32.0 kB
Release files / vs_security-0.1.7.tar.gz
| Download URL | vs_security-0.1.7.tar.gz |
|---|---|
| Size | 16.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
37c88dc5130de8d04202eb13b06a86ca7ca3c2e38513c470fdf29678f23e3ee9
|
|
BLAKE2b-256 checksum How to use checksums |
bb851207f5c95b019bb61c4d677e6ff8a32a0e1e51d6a55dc48339208d1ce9d0
|
| 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.7-py3-none-any.whl
| Download URL | vs_security-0.1.7-py3-none-any.whl |
|---|---|
| Size | 15.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2631f8c2e014534c62ca1f398c6006ed7ebd0c1520771e5c665c171264ad38b9
|
|
BLAKE2b-256 checksum How to use checksums |
bf1f8a2153f2f45b6f731c7a40b4ca09a03bdfca659285335a60006afb2df0e8
|
| 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.7
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.