feat: Add Auth Service with JWT authentication

This commit is contained in:
Apple
2025-11-26 11:47:00 -08:00
parent 2c4eb7d432
commit 5aaf6cbf21
23 changed files with 3522 additions and 26 deletions

View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 7020
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:7020/healthz').raise_for_status()"
# Run
CMD ["python", "main.py"]

View File

@@ -0,0 +1,220 @@
# Auth Service
**Port:** 7011
**Purpose:** Identity & session management for DAARION
## Features
**Session Management:**
- Login with email (Phase 4: mock users)
- Session tokens (7-day expiry)
- Logout
**API Keys:**
- Create API keys for programmatic access
- List/delete keys
- Optional expiration
**Actor Context:**
- Unified ActorIdentity model
- Supports: human, agent, service actors
- MicroDAO membership + roles
## Actor Model
### ActorIdentity
```json
{
"actor_id": "user:93",
"actor_type": "human",
"microdao_ids": ["microdao:daarion", "microdao:7"],
"roles": ["member", "microdao_owner"]
}
```
**Actor Types:**
- `human` — Real users
- `agent` — AI agents
- `service` — Internal services (llm-proxy, etc.)
**Roles:**
- `system_admin` — Full system access
- `microdao_owner` — Owner of a microDAO
- `admin` — Admin in a microDAO
- `member` — Regular member
- `agent` — Agent role
## API
### POST /auth/login
```bash
curl -X POST http://localhost:7011/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@daarion.city",
"password": "any"
}'
```
**Response:**
```json
{
"session_token": "...",
"actor": {
"actor_id": "user:93",
"actor_type": "human",
"microdao_ids": ["microdao:daarion"],
"roles": ["member"]
},
"expires_at": "2025-12-01T12:00:00Z"
}
```
**Mock Users (Phase 4):**
- `admin@daarion.city` → system_admin
- `user@daarion.city` → regular user
- `sofia@agents.daarion.city` → agent
### GET /auth/me
Get current actor:
```bash
curl http://localhost:7011/auth/me \
-H "Authorization: Bearer <session_token>"
```
### POST /auth/logout
```bash
curl -X POST http://localhost:7011/auth/logout \
-H "Authorization: Bearer <session_token>"
```
### POST /auth/api-keys
Create API key:
```bash
curl -X POST http://localhost:7011/auth/api-keys \
-H "Authorization: Bearer <session_token>" \
-H "Content-Type: application/json" \
-d '{
"description": "My API key",
"expires_days": 30
}'
```
**Response:**
```json
{
"id": "key-123",
"key": "dk_abc123...",
"actor_id": "user:93",
"description": "My API key",
"created_at": "...",
"expires_at": "..."
}
```
⚠️ **Key shown only once!**
### GET /auth/api-keys
List keys:
```bash
curl http://localhost:7011/auth/api-keys \
-H "Authorization: Bearer <session_token>"
```
### DELETE /auth/api-keys/{key_id}
```bash
curl -X DELETE http://localhost:7011/auth/api-keys/key-123 \
-H "Authorization: Bearer <session_token>"
```
## Integration
### In Other Services
```python
from actor_context import require_actor
from models import ActorIdentity
@app.get("/protected")
async def protected_route(
actor: ActorIdentity = Depends(require_actor)
):
# actor.actor_id, actor.roles, etc.
...
```
### Authentication Priority
1. **X-API-Key header** (for services)
2. **Authorization: Bearer <token>** (for API clients)
3. **session_token cookie** (for web UI)
## Database Schema
### sessions
```sql
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
actor_id TEXT NOT NULL,
actor_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
is_valid BOOLEAN DEFAULT true
);
```
### api_keys
```sql
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
actor_id TEXT NOT NULL,
actor_data JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true
);
```
## Setup
### Local Development
```bash
cd services/auth-service
pip install -r requirements.txt
python main.py
```
### Docker
```bash
docker build -t auth-service .
docker run -p 7011:7011 \
-e DATABASE_URL="postgresql://..." \
auth-service
```
## Roadmap
### Phase 4 (Current):
- ✅ Mock login
- ✅ Session tokens
- ✅ API keys
- ✅ ActorContext helper
### Phase 5:
- 🔜 Real Passkey integration
- 🔜 OAuth2 providers
- 🔜 Multi-factor auth
- 🔜 Session refresh tokens
---
**Status:** ✅ Phase 4 Ready
**Version:** 1.0.0
**Last Updated:** 2025-11-24

View File

@@ -0,0 +1,129 @@
"""
Actor Context Builder
Extracts ActorIdentity from request (session token or API key)
"""
import asyncpg
from fastapi import Header, HTTPException, Cookie
from typing import Optional
from models import ActorIdentity, ActorType
import json
async def build_actor_context(
db_pool: asyncpg.Pool,
authorization: Optional[str] = Header(None),
session_token: Optional[str] = Cookie(None),
x_api_key: Optional[str] = Header(None, alias="X-API-Key")
) -> ActorIdentity:
"""
Build ActorIdentity from request
Priority:
1. X-API-Key header
2. Authorization header (Bearer token)
3. session_token cookie
Raises HTTPException(401) if no valid credentials
"""
# Try API Key first
if x_api_key:
actor = await get_actor_from_api_key(db_pool, x_api_key)
if actor:
return actor
# Try Authorization header
if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
actor = await get_actor_from_session(db_pool, token)
if actor:
return actor
# Try session cookie
if session_token:
actor = await get_actor_from_session(db_pool, session_token)
if actor:
return actor
raise HTTPException(
status_code=401,
detail="Unauthorized: No valid session token or API key"
)
async def get_actor_from_session(db_pool: asyncpg.Pool, token: str) -> Optional[ActorIdentity]:
"""Get ActorIdentity from session token"""
async with db_pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT actor_id, actor_data, expires_at
FROM sessions
WHERE token = $1 AND is_valid = true
""",
token
)
if not row:
return None
# Check expiration
from datetime import datetime, timezone
if row['expires_at'] < datetime.now(timezone.utc):
# Expired
await conn.execute("UPDATE sessions SET is_valid = false WHERE token = $1", token)
return None
# Parse actor data
actor_data = row['actor_data']
return ActorIdentity(**actor_data)
async def get_actor_from_api_key(db_pool: asyncpg.Pool, key: str) -> Optional[ActorIdentity]:
"""Get ActorIdentity from API key"""
async with db_pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT actor_id, actor_data, expires_at, last_used
FROM api_keys
WHERE key = $1 AND is_active = true
""",
key
)
if not row:
return None
# Check expiration
from datetime import datetime, timezone
if row['expires_at'] and row['expires_at'] < datetime.now(timezone.utc):
# Expired
await conn.execute("UPDATE api_keys SET is_active = false WHERE key = $1", key)
return None
# Update last_used
await conn.execute(
"UPDATE api_keys SET last_used = NOW() WHERE key = $1",
key
)
# Parse actor data
actor_data = row['actor_data']
return ActorIdentity(**actor_data)
async def require_actor(
db_pool: asyncpg.Pool,
authorization: Optional[str] = Header(None),
session_token: Optional[str] = Cookie(None),
x_api_key: Optional[str] = Header(None, alias="X-API-Key")
) -> ActorIdentity:
"""
Dependency for routes that require authentication
Usage:
@app.get("/protected")
async def protected_route(actor: ActorIdentity = Depends(require_actor)):
...
"""
return await build_actor_context(db_pool, authorization, session_token, x_api_key)

View File

@@ -0,0 +1,35 @@
"""
Auth Service Configuration
"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Service
service_name: str = "auth-service"
service_version: str = "1.0.0"
port: int = 7020
debug: bool = False
# Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/daarion"
# JWT
jwt_secret: str = "your-very-long-secret-key-change-in-production"
jwt_algorithm: str = "HS256"
access_token_ttl: int = 1800 # 30 minutes
refresh_token_ttl: int = 604800 # 7 days
# Security
bcrypt_rounds: int = 12
class Config:
env_prefix = "AUTH_"
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,183 @@
"""
Database connection and operations for Auth Service
"""
import asyncpg
from typing import Optional, List, Dict, Any
from uuid import UUID
from datetime import datetime, timedelta, timezone
from contextlib import asynccontextmanager
from config import get_settings
settings = get_settings()
_pool: Optional[asyncpg.Pool] = None
async def get_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(
settings.database_url,
min_size=2,
max_size=10
)
return _pool
async def close_pool():
global _pool
if _pool:
await _pool.close()
_pool = None
@asynccontextmanager
async def get_connection():
pool = await get_pool()
async with pool.acquire() as conn:
yield conn
# User operations
async def create_user(
email: str,
password_hash: str,
display_name: Optional[str] = None
) -> Dict[str, Any]:
async with get_connection() as conn:
# Create user
user = await conn.fetchrow(
"""
INSERT INTO auth_users (email, password_hash, display_name)
VALUES ($1, $2, $3)
RETURNING id, email, display_name, avatar_url, is_active, is_admin, created_at
""",
email, password_hash, display_name
)
user_id = user['id']
# Assign default 'user' role
await conn.execute(
"""
INSERT INTO auth_user_roles (user_id, role_id)
VALUES ($1, 'user')
""",
user_id
)
return dict(user)
async def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
async with get_connection() as conn:
user = await conn.fetchrow(
"""
SELECT id, email, password_hash, display_name, avatar_url,
is_active, is_admin, created_at, updated_at
FROM auth_users
WHERE email = $1
""",
email
)
return dict(user) if user else None
async def get_user_by_id(user_id: UUID) -> Optional[Dict[str, Any]]:
async with get_connection() as conn:
user = await conn.fetchrow(
"""
SELECT id, email, display_name, avatar_url,
is_active, is_admin, created_at, updated_at
FROM auth_users
WHERE id = $1
""",
user_id
)
return dict(user) if user else None
async def get_user_roles(user_id: UUID) -> List[str]:
async with get_connection() as conn:
rows = await conn.fetch(
"""
SELECT role_id FROM auth_user_roles
WHERE user_id = $1
""",
user_id
)
return [row['role_id'] for row in rows]
# Session operations
async def create_session(
user_id: UUID,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
ttl_seconds: int = 604800
) -> UUID:
async with get_connection() as conn:
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
row = await conn.fetchrow(
"""
INSERT INTO auth_sessions (user_id, user_agent, ip_address, expires_at)
VALUES ($1, $2, $3::inet, $4)
RETURNING id
""",
user_id, user_agent, ip_address, expires_at
)
return row['id']
async def get_session(session_id: UUID) -> Optional[Dict[str, Any]]:
async with get_connection() as conn:
session = await conn.fetchrow(
"""
SELECT id, user_id, expires_at, revoked_at
FROM auth_sessions
WHERE id = $1
""",
session_id
)
return dict(session) if session else None
async def revoke_session(session_id: UUID) -> bool:
async with get_connection() as conn:
result = await conn.execute(
"""
UPDATE auth_sessions
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL
""",
session_id
)
return result == "UPDATE 1"
async def is_session_valid(session_id: UUID) -> bool:
async with get_connection() as conn:
row = await conn.fetchrow(
"""
SELECT 1 FROM auth_sessions
WHERE id = $1
AND revoked_at IS NULL
AND expires_at > now()
""",
session_id
)
return row is not None
async def cleanup_expired_sessions():
"""Remove expired sessions (can be run periodically)"""
async with get_connection() as conn:
await conn.execute(
"""
DELETE FROM auth_sessions
WHERE expires_at < now() - INTERVAL '7 days'
"""
)

View File

@@ -0,0 +1,304 @@
"""
Auth Service - Main Application
DAARION.city Authentication & Authorization
"""
from fastapi import FastAPI, HTTPException, Depends, Request, Header
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from typing import Optional
from uuid import UUID
import logging
from config import get_settings
from models import (
RegisterRequest, RegisterResponse,
LoginRequest, TokenResponse,
RefreshRequest, RefreshResponse,
LogoutRequest, StatusResponse,
IntrospectRequest, IntrospectResponse,
UserResponse, HealthResponse, ErrorResponse
)
from database import (
get_pool, close_pool,
create_user, get_user_by_email, get_user_by_id, get_user_roles,
create_session, get_session, revoke_session, is_session_valid
)
from security import (
hash_password, verify_password,
create_access_token, create_refresh_token,
decode_access_token, decode_refresh_token
)
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info(f"Starting {settings.service_name} v{settings.service_version}")
await get_pool()
yield
# Shutdown
await close_pool()
logger.info("Auth service stopped")
app = FastAPI(
title="DAARION Auth Service",
description="Authentication & Authorization for DAARION.city",
version=settings.service_version,
lifespan=lifespan
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify exact origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Dependency: Get current user from token
async def get_current_user(
authorization: Optional[str] = Header(None)
) -> Optional[dict]:
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization[7:] # Remove "Bearer " prefix
payload = decode_access_token(token)
if not payload:
return None
return payload
async def require_auth(
authorization: Optional[str] = Header(None)
) -> dict:
user = await get_current_user(authorization)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
# Health check
@app.get("/healthz", response_model=HealthResponse)
async def health_check():
return HealthResponse(
status="ok",
service=settings.service_name,
version=settings.service_version
)
# Register
@app.post("/api/auth/register", response_model=RegisterResponse, status_code=201)
async def register(request: RegisterRequest):
# Check if user exists
existing = await get_user_by_email(request.email)
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# Hash password and create user
password_hash = hash_password(request.password)
user = await create_user(
email=request.email,
password_hash=password_hash,
display_name=request.display_name
)
logger.info(f"User registered: {request.email}")
return RegisterResponse(
user_id=user['id'],
email=user['email'],
display_name=user['display_name'],
roles=["user"]
)
# Login
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(
request: LoginRequest,
req: Request
):
# Get user
user = await get_user_by_email(request.email)
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
# Verify password
if not verify_password(request.password, user['password_hash']):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Check if active
if not user['is_active']:
raise HTTPException(status_code=403, detail="Account is disabled")
# Get roles
roles = await get_user_roles(user['id'])
if user['is_admin'] and 'admin' not in roles:
roles.append('admin')
# Create session
user_agent = req.headers.get("user-agent")
ip_address = req.client.host if req.client else None
session_id = await create_session(
user_id=user['id'],
user_agent=user_agent,
ip_address=ip_address,
ttl_seconds=settings.refresh_token_ttl
)
# Create tokens
access_token = create_access_token(
user_id=user['id'],
email=user['email'],
display_name=user['display_name'],
roles=roles
)
refresh_token = create_refresh_token(
user_id=user['id'],
session_id=session_id
)
logger.info(f"User logged in: {request.email}")
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=settings.access_token_ttl,
user=UserResponse(
id=user['id'],
email=user['email'],
display_name=user['display_name'],
avatar_url=user['avatar_url'],
roles=roles,
is_active=user['is_active'],
created_at=user['created_at']
)
)
# Refresh token
@app.post("/api/auth/refresh", response_model=RefreshResponse)
async def refresh(request: RefreshRequest):
# Decode refresh token
payload = decode_refresh_token(request.refresh_token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid refresh token")
# Check session
session_id = UUID(payload['session_id'])
if not await is_session_valid(session_id):
raise HTTPException(status_code=401, detail="Session expired or revoked")
# Get user
user_id = UUID(payload['sub'])
user = await get_user_by_id(user_id)
if not user or not user['is_active']:
raise HTTPException(status_code=401, detail="User not found or disabled")
# Get roles
roles = await get_user_roles(user_id)
if user['is_admin'] and 'admin' not in roles:
roles.append('admin')
# Revoke old session and create new one
await revoke_session(session_id)
new_session_id = await create_session(
user_id=user_id,
ttl_seconds=settings.refresh_token_ttl
)
# Create new tokens
access_token = create_access_token(
user_id=user_id,
email=user['email'],
display_name=user['display_name'],
roles=roles
)
new_refresh_token = create_refresh_token(
user_id=user_id,
session_id=new_session_id
)
return RefreshResponse(
access_token=access_token,
refresh_token=new_refresh_token,
token_type="Bearer",
expires_in=settings.access_token_ttl
)
# Logout
@app.post("/api/auth/logout", response_model=StatusResponse)
async def logout(request: LogoutRequest):
# Decode refresh token
payload = decode_refresh_token(request.refresh_token)
if payload:
session_id = UUID(payload['session_id'])
await revoke_session(session_id)
return StatusResponse(status="ok")
# Get current user
@app.get("/api/auth/me", response_model=UserResponse)
async def get_me(current_user: dict = Depends(require_auth)):
user_id = UUID(current_user['sub'])
user = await get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
roles = await get_user_roles(user_id)
if user['is_admin'] and 'admin' not in roles:
roles.append('admin')
return UserResponse(
id=user['id'],
email=user['email'],
display_name=user['display_name'],
avatar_url=user['avatar_url'],
roles=roles,
is_active=user['is_active'],
created_at=user['created_at']
)
# Introspect token (for other services)
@app.post("/api/auth/introspect", response_model=IntrospectResponse)
async def introspect(request: IntrospectRequest):
payload = decode_access_token(request.token)
if not payload:
return IntrospectResponse(active=False)
return IntrospectResponse(
active=True,
sub=payload.get('sub'),
email=payload.get('email'),
roles=payload.get('roles', []),
exp=payload.get('exp')
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=settings.port,
reload=settings.debug
)

View File

@@ -0,0 +1,86 @@
"""
Auth Service Data Models
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
# Request Models
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
display_name: Optional[str] = Field(None, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str
class IntrospectRequest(BaseModel):
token: str
# Response Models
class UserResponse(BaseModel):
id: UUID
email: str
display_name: Optional[str] = None
avatar_url: Optional[str] = None
roles: List[str] = []
is_active: bool = True
created_at: datetime
class RegisterResponse(BaseModel):
user_id: UUID
email: str
display_name: Optional[str] = None
roles: List[str] = ["user"]
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int
user: UserResponse
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int
class IntrospectResponse(BaseModel):
active: bool
sub: Optional[str] = None
email: Optional[str] = None
roles: Optional[List[str]] = None
exp: Optional[int] = None
class StatusResponse(BaseModel):
status: str
class HealthResponse(BaseModel):
status: str
service: str
version: str
class ErrorResponse(BaseModel):
detail: str

View File

@@ -0,0 +1,230 @@
"""
Passkey Store - Database operations for WebAuthn credentials
"""
import asyncpg
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
import base64
class PasskeyStore:
"""Database layer for passkey operations"""
def __init__(self, db_pool: asyncpg.Pool):
self.db_pool = db_pool
# ========================================================================
# User Operations
# ========================================================================
async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
"""Get user by email"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM users WHERE email = $1",
email
)
return dict(row) if row else None
async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Get user by ID"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM users WHERE id = $1::uuid",
user_id
)
return dict(row) if row else None
async def create_user(
self,
email: str,
username: str,
display_name: str
) -> Dict[str, Any]:
"""Create new user"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO users (email, username, display_name)
VALUES ($1, $2, $3)
RETURNING *
""", email, username, display_name)
return dict(row)
async def update_last_login(self, user_id: str):
"""Update user's last login timestamp"""
async with self.db_pool.acquire() as conn:
await conn.execute(
"UPDATE users SET last_login_at = now() WHERE id = $1::uuid",
user_id
)
# ========================================================================
# Passkey Operations
# ========================================================================
async def create_passkey(
self,
user_id: str,
credential_id: str,
public_key: str,
sign_count: int = 0,
device_name: Optional[str] = None,
transports: Optional[List[str]] = None,
aaguid: Optional[str] = None,
attestation_format: Optional[str] = None
) -> Dict[str, Any]:
"""Store new passkey credential"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO passkeys
(user_id, credential_id, public_key, sign_count, device_name, transports, aaguid, attestation_format)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
""",
user_id, credential_id, public_key, sign_count,
device_name, transports, aaguid, attestation_format
)
return dict(row)
async def get_passkeys_by_user_id(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all passkeys for a user"""
async with self.db_pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM passkeys WHERE user_id = $1::uuid ORDER BY created_at DESC",
user_id
)
return [dict(row) for row in rows]
async def get_passkey_by_credential_id(self, credential_id: str) -> Optional[Dict[str, Any]]:
"""Get passkey by credential ID"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM passkeys WHERE credential_id = $1",
credential_id
)
return dict(row) if row else None
async def update_sign_count(self, credential_id: str, new_sign_count: int):
"""Update passkey sign count and last used timestamp"""
async with self.db_pool.acquire() as conn:
await conn.execute("""
UPDATE passkeys
SET sign_count = $2, last_used_at = now()
WHERE credential_id = $1
""", credential_id, new_sign_count)
# ========================================================================
# Challenge Operations
# ========================================================================
async def store_challenge(
self,
challenge: str,
challenge_type: str,
user_id: Optional[str] = None,
email: Optional[str] = None,
expires_in_seconds: int = 300 # 5 minutes
):
"""Store challenge for verification"""
async with self.db_pool.acquire() as conn:
await conn.execute("""
INSERT INTO passkey_challenges
(challenge, user_id, email, challenge_type, rp_id, origin, expires_at)
VALUES ($1, $2::uuid, $3, $4, $5, $6, now() + interval '%s seconds')
""" % expires_in_seconds,
challenge, user_id, email, challenge_type,
"localhost", "http://localhost:3000"
)
async def verify_challenge(
self,
challenge: str,
challenge_type: str
) -> Optional[Dict[str, Any]]:
"""Verify and consume challenge"""
async with self.db_pool.acquire() as conn:
# Get challenge
row = await conn.fetchrow("""
SELECT * FROM passkey_challenges
WHERE challenge = $1
AND challenge_type = $2
AND expires_at > now()
""", challenge, challenge_type)
if not row:
return None
# Delete challenge (one-time use)
await conn.execute(
"DELETE FROM passkey_challenges WHERE challenge = $1",
challenge
)
return dict(row)
async def cleanup_expired_challenges(self):
"""Remove expired challenges"""
async with self.db_pool.acquire() as conn:
await conn.execute(
"DELETE FROM passkey_challenges WHERE expires_at < now()"
)
# ========================================================================
# Session Operations
# ========================================================================
async def create_session(
self,
token: str,
user_id: str,
expires_in_days: int = 30
) -> Dict[str, Any]:
"""Create new session"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO sessions (token, user_id, expires_at)
VALUES ($1, $2::uuid, now() + interval '%s days')
RETURNING *
""" % expires_in_days, token, user_id)
return dict(row)
async def get_session(self, token: str) -> Optional[Dict[str, Any]]:
"""Get session by token"""
async with self.db_pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT * FROM sessions
WHERE token = $1 AND expires_at > now()
""", token)
return dict(row) if row else None
async def delete_session(self, token: str):
"""Delete session"""
async with self.db_pool.acquire() as conn:
await conn.execute(
"DELETE FROM sessions WHERE token = $1",
token
)
async def cleanup_expired_sessions(self):
"""Remove expired sessions"""
async with self.db_pool.acquire() as conn:
await conn.execute(
"DELETE FROM sessions WHERE expires_at < now()"
)
# ========================================================================
# MicroDAO Memberships (for ActorIdentity)
# ========================================================================
async def get_user_microdao_memberships(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all microDAO memberships for a user"""
async with self.db_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT * FROM user_microdao_memberships
WHERE user_id = $1::uuid AND left_at IS NULL
ORDER BY joined_at DESC
""", user_id)
return [dict(row) for row in rows]

View File

@@ -0,0 +1,11 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
asyncpg==0.29.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
python-multipart==0.0.6
email-validator==2.1.0
httpx==0.26.0

View File

@@ -0,0 +1,127 @@
"""
API Key management routes
"""
from fastapi import APIRouter, HTTPException, Depends
import asyncpg
from datetime import datetime, timedelta, timezone
import secrets
from models import ApiKeyCreateRequest, ApiKey, ApiKeyResponse, ActorIdentity
from actor_context import require_actor
import json
router = APIRouter(prefix="/auth/api-keys", tags=["api_keys"])
def get_db_pool(request) -> asyncpg.Pool:
"""Get database pool from app state"""
return request.app.state.db_pool
@router.post("", response_model=ApiKey)
async def create_api_key(
request: ApiKeyCreateRequest,
actor: ActorIdentity = Depends(require_actor),
db_pool: asyncpg.Pool = Depends(get_db_pool)
):
"""
Create new API key for current actor
Returns full key only once (on creation)
"""
# Generate key
key = f"dk_{secrets.token_urlsafe(32)}"
key_id = secrets.token_urlsafe(16)
# Calculate expiration
expires_at = None
if request.expires_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=request.expires_days)
# Store in database
async with db_pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO api_keys (id, key, actor_id, actor_data, description, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
""",
key_id,
key,
actor.actor_id,
json.dumps(actor.model_dump()),
request.description,
expires_at
)
return ApiKey(
id=key_id,
key=key, # Full key shown only once
actor_id=actor.actor_id,
actor=actor,
description=request.description,
created_at=datetime.now(timezone.utc),
expires_at=expires_at,
last_used=None,
is_active=True
)
@router.get("", response_model=list[ApiKeyResponse])
async def list_api_keys(
actor: ActorIdentity = Depends(require_actor),
db_pool: asyncpg.Pool = Depends(get_db_pool)
):
"""List all API keys for current actor"""
async with db_pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, key, description, created_at, expires_at, last_used, is_active
FROM api_keys
WHERE actor_id = $1
ORDER BY created_at DESC
""",
actor.actor_id
)
keys = []
for row in rows:
# Show only preview of key
key_preview = row['key'][:8] + "..." if len(row['key']) > 8 else row['key']
keys.append(ApiKeyResponse(
id=row['id'],
key_preview=key_preview,
description=row['description'],
created_at=row['created_at'],
expires_at=row['expires_at'],
last_used=row['last_used'],
is_active=row['is_active']
))
return keys
@router.delete("/{key_id}")
async def delete_api_key(
key_id: str,
actor: ActorIdentity = Depends(require_actor),
db_pool: asyncpg.Pool = Depends(get_db_pool)
):
"""Delete (deactivate) API key"""
async with db_pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE api_keys
SET is_active = false
WHERE id = $1 AND actor_id = $2
""",
key_id,
actor.actor_id
)
if result == "UPDATE 0":
raise HTTPException(404, "API key not found")
return {"status": "deleted", "key_id": key_id}

View File

@@ -0,0 +1,329 @@
"""
Passkey Routes (WebAuthn)
4 endpoints: register/start, register/finish, authenticate/start, authenticate/finish
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import base64
import json
from passkey_store import PasskeyStore
from webauthn_utils import webauthn_manager, generate_session_token
from models import ActorIdentity, ActorType
router = APIRouter(prefix="/auth/passkey", tags=["passkey"])
# Global store (injected at startup)
passkey_store: Optional[PasskeyStore] = None
def get_store() -> PasskeyStore:
if not passkey_store:
raise HTTPException(500, "Passkey store not initialized")
return passkey_store
# ============================================================================
# Request/Response Models
# ============================================================================
class RegistrationStartRequest(BaseModel):
email: str = Field(..., min_length=3, max_length=255)
username: Optional[str] = None
display_name: Optional[str] = None
class RegistrationStartResponse(BaseModel):
options: Dict[str, Any]
challenge: str
class RegistrationFinishRequest(BaseModel):
email: str
credential: Dict[str, Any] # WebAuthn credential response
class RegistrationFinishResponse(BaseModel):
success: bool
user_id: str
message: str
class AuthenticationStartRequest(BaseModel):
email: Optional[str] = None # Optional for resident key
class AuthenticationStartResponse(BaseModel):
options: Dict[str, Any]
challenge: str
class AuthenticationFinishRequest(BaseModel):
credential: Dict[str, Any] # WebAuthn assertion response
class AuthenticationFinishResponse(BaseModel):
session_token: str
actor: ActorIdentity
# ============================================================================
# REGISTRATION FLOW
# ============================================================================
@router.post("/register/start", response_model=RegistrationStartResponse)
async def register_start(
request: RegistrationStartRequest,
store: PasskeyStore = Depends(get_store)
):
"""
Step 1 of registration: Generate WebAuthn challenge
Creates or finds user, generates registration options
"""
# Check if user already exists
user = await store.get_user_by_email(request.email)
if not user:
# Create new user
username = request.username or request.email.split('@')[0]
display_name = request.display_name or username
user = await store.create_user(
email=request.email,
username=username,
display_name=display_name
)
print(f"✅ Created new user: {user['id']}")
else:
print(f"✅ Found existing user: {user['id']}")
# Generate registration options
result = webauthn_manager.generate_registration_challenge(
user_id=str(user['id']),
username=user['username'],
display_name=user['display_name']
)
# Store challenge
await store.store_challenge(
challenge=result['challenge'],
challenge_type='register',
user_id=str(user['id']),
email=request.email
)
print(f"✅ Generated registration challenge for {request.email}")
return RegistrationStartResponse(
options=result['options'],
challenge=result['challenge']
)
@router.post("/register/finish", response_model=RegistrationFinishResponse)
async def register_finish(
request: RegistrationFinishRequest,
store: PasskeyStore = Depends(get_store)
):
"""
Step 2 of registration: Verify attestation and store credential
Validates WebAuthn response, stores public key
"""
# Get user
user = await store.get_user_by_email(request.email)
if not user:
raise HTTPException(404, "User not found")
# Extract challenge from credential
client_data_json = base64.urlsafe_b64decode(
request.credential['response']['clientDataJSON'] + "=="
)
client_data = json.loads(client_data_json)
challenge_b64 = client_data['challenge']
# Verify challenge
challenge_record = await store.verify_challenge(
challenge=challenge_b64,
challenge_type='register'
)
if not challenge_record:
raise HTTPException(400, "Invalid or expired challenge")
# Verify registration
expected_challenge = base64.urlsafe_b64decode(challenge_b64 + "==")
verification = webauthn_manager.verify_registration(
credential=request.credential,
expected_challenge=expected_challenge,
expected_origin=webauthn_manager.origin,
expected_rp_id=webauthn_manager.rp_id
)
if not verification['verified']:
raise HTTPException(400, f"Registration verification failed: {verification.get('error')}")
# Store passkey
await store.create_passkey(
user_id=str(user['id']),
credential_id=verification['credential_id'],
public_key=verification['public_key'],
sign_count=verification['sign_count'],
aaguid=verification['aaguid'],
attestation_format=verification['attestation_format']
)
print(f"✅ Registered passkey for user {user['id']}")
return RegistrationFinishResponse(
success=True,
user_id=str(user['id']),
message="Passkey registered successfully"
)
# ============================================================================
# AUTHENTICATION FLOW
# ============================================================================
@router.post("/authenticate/start", response_model=AuthenticationStartResponse)
async def authenticate_start(
request: AuthenticationStartRequest,
store: PasskeyStore = Depends(get_store)
):
"""
Step 1 of authentication: Generate WebAuthn challenge
Finds user's passkeys, generates authentication options
"""
credentials = []
user_id = None
if request.email:
# Email-based authentication
user = await store.get_user_by_email(request.email)
if not user:
raise HTTPException(404, "User not found")
user_id = str(user['id'])
# Get user's passkeys
passkeys = await store.get_passkeys_by_user_id(user_id)
credentials = [
{
"credential_id": pk['credential_id'],
"transports": pk.get('transports', [])
}
for pk in passkeys
]
if not credentials:
raise HTTPException(404, "No passkeys found for this user")
else:
# Resident key authentication (discoverable credential)
# Allow any passkey
pass
# Generate authentication options
result = webauthn_manager.generate_authentication_challenge(credentials)
# Store challenge
await store.store_challenge(
challenge=result['challenge'],
challenge_type='authenticate',
user_id=user_id,
email=request.email
)
print(f"✅ Generated authentication challenge")
return AuthenticationStartResponse(
options=result['options'],
challenge=result['challenge']
)
@router.post("/authenticate/finish", response_model=AuthenticationFinishResponse)
async def authenticate_finish(
request: AuthenticationFinishRequest,
store: PasskeyStore = Depends(get_store)
):
"""
Step 2 of authentication: Verify assertion and create session
Validates WebAuthn response, returns session token
"""
# Extract credential ID and challenge
credential_id_b64 = request.credential['id']
client_data_json = base64.urlsafe_b64decode(
request.credential['response']['clientDataJSON'] + "=="
)
client_data = json.loads(client_data_json)
challenge_b64 = client_data['challenge']
# Verify challenge
challenge_record = await store.verify_challenge(
challenge=challenge_b64,
challenge_type='authenticate'
)
if not challenge_record:
raise HTTPException(400, "Invalid or expired challenge")
# Get passkey
passkey = await store.get_passkey_by_credential_id(credential_id_b64)
if not passkey:
raise HTTPException(404, "Passkey not found")
# Verify authentication
expected_challenge = base64.urlsafe_b64decode(challenge_b64 + "==")
public_key_bytes = base64.urlsafe_b64decode(passkey['public_key'] + "==")
verification = webauthn_manager.verify_authentication(
credential=request.credential,
expected_challenge=expected_challenge,
credential_public_key=public_key_bytes,
credential_current_sign_count=passkey['sign_count'],
expected_origin=webauthn_manager.origin,
expected_rp_id=webauthn_manager.rp_id
)
if not verification['verified']:
raise HTTPException(400, f"Authentication verification failed: {verification.get('error')}")
# Update sign count
await store.update_sign_count(
credential_id=credential_id_b64,
new_sign_count=verification['new_sign_count']
)
# Get user
user = await store.get_user_by_id(str(passkey['user_id']))
if not user:
raise HTTPException(404, "User not found")
# Update last login
await store.update_last_login(str(user['id']))
# Create session
session_token = generate_session_token()
await store.create_session(
token=session_token,
user_id=str(user['id'])
)
# Build ActorIdentity
memberships = await store.get_user_microdao_memberships(str(user['id']))
actor = ActorIdentity(
actor_id=f"user:{user['id']}",
actor_type=ActorType.HUMAN,
microdao_ids=[m['microdao_id'] for m in memberships],
roles=[m['role'] for m in memberships]
)
print(f"✅ Authenticated user {user['id']}")
return AuthenticationFinishResponse(
session_token=session_token,
actor=actor
)

View File

@@ -0,0 +1,129 @@
"""
Session management routes
"""
from fastapi import APIRouter, HTTPException, Depends, Response
import asyncpg
from datetime import datetime, timedelta, timezone
import secrets
from models import LoginRequest, LoginResponse, ActorIdentity, ActorType
from actor_context import require_actor
import json
router = APIRouter(prefix="/auth", tags=["sessions"])
# Mock users for Phase 4
# In production, this would be in database with proper password hashing
MOCK_USERS = {
"admin@daarion.city": {
"actor_id": "user:1",
"actor_type": "human",
"microdao_ids": ["microdao:daarion"],
"roles": ["system_admin", "microdao_owner"]
},
"user@daarion.city": {
"actor_id": "user:93",
"actor_type": "human",
"microdao_ids": ["microdao:daarion", "microdao:7"],
"roles": ["member", "microdao_owner"]
},
"sofia@agents.daarion.city": {
"actor_id": "agent:sofia",
"actor_type": "agent",
"microdao_ids": ["microdao:daarion"],
"roles": ["agent"]
}
}
def get_db_pool(request) -> asyncpg.Pool:
"""Get database pool from app state"""
return request.app.state.db_pool
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
response: Response,
db_pool: asyncpg.Pool = Depends(get_db_pool)
):
"""
Login and get session token
Phase 4: Mock implementation with predefined users
Phase 5: Real Passkey integration
"""
# Check mock users
if request.email not in MOCK_USERS:
raise HTTPException(401, "Invalid credentials")
user_data = MOCK_USERS[request.email]
# Build ActorIdentity
actor = ActorIdentity(
actor_id=user_data["actor_id"],
actor_type=ActorType(user_data["actor_type"]),
microdao_ids=user_data["microdao_ids"],
roles=user_data["roles"]
)
# Generate session token
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
# Store in database
async with db_pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO sessions (token, actor_id, actor_data, expires_at)
VALUES ($1, $2, $3, $4)
""",
token,
actor.actor_id,
json.dumps(actor.model_dump()),
expires_at
)
# Set cookie
response.set_cookie(
key="session_token",
value=token,
httponly=True,
max_age=7 * 24 * 60 * 60, # 7 days
samesite="lax"
)
return LoginResponse(
session_token=token,
actor=actor,
expires_at=expires_at
)
@router.get("/me", response_model=ActorIdentity)
async def get_me(
actor: ActorIdentity = Depends(require_actor)
):
"""Get current actor identity"""
return actor
@router.post("/logout")
async def logout(
response: Response,
actor: ActorIdentity = Depends(require_actor),
db_pool: asyncpg.Pool = Depends(get_db_pool)
):
"""Logout and invalidate session"""
# Invalidate all sessions for this actor
async with db_pool.acquire() as conn:
await conn.execute(
"UPDATE sessions SET is_valid = false WHERE actor_id = $1",
actor.actor_id
)
# Clear cookie
response.delete_cookie("session_token")
return {"status": "logged_out"}

View File

@@ -0,0 +1,102 @@
"""
Security utilities: password hashing, JWT tokens
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from uuid import UUID
from jose import jwt, JWTError
from passlib.context import CryptContext
from config import get_settings
settings = get_settings()
# Password hashing
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__rounds=settings.bcrypt_rounds
)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return pwd_context.verify(plain_password, hashed_password)
# JWT Token operations
def create_access_token(
user_id: UUID,
email: str,
display_name: Optional[str],
roles: list[str]
) -> str:
"""Create a JWT access token"""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.access_token_ttl)
payload = {
"sub": str(user_id),
"email": email,
"name": display_name,
"roles": roles,
"type": "access",
"iss": "daarion-auth",
"iat": int(now.timestamp()),
"exp": int(expire.timestamp())
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def create_refresh_token(user_id: UUID, session_id: UUID) -> str:
"""Create a JWT refresh token"""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.refresh_token_ttl)
payload = {
"sub": str(user_id),
"session_id": str(session_id),
"type": "refresh",
"iss": "daarion-auth",
"iat": int(now.timestamp()),
"exp": int(expire.timestamp())
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode and validate a JWT token"""
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
options={"verify_exp": True}
)
return payload
except JWTError:
return None
def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode an access token and verify it's the correct type"""
payload = decode_token(token)
if payload and payload.get("type") == "access":
return payload
return None
def decode_refresh_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode a refresh token and verify it's the correct type"""
payload = decode_token(token)
if payload and payload.get("type") == "refresh":
return payload
return None

View File

@@ -0,0 +1,209 @@
"""
WebAuthn Utilities for DAARION
Handles challenge generation, credential validation, and attestation
"""
import os
import secrets
import base64
import json
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
import hashlib
# WebAuthn library
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
UserVerificationRequirement,
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
AuthenticatorAttachment,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
# Configuration
RP_ID = os.getenv("RP_ID", "localhost")
RP_NAME = os.getenv("RP_NAME", "DAARION")
ORIGIN = os.getenv("ORIGIN", "http://localhost:3000")
class WebAuthnManager:
"""Manages WebAuthn operations"""
def __init__(self):
self.rp_id = RP_ID
self.rp_name = RP_NAME
self.origin = ORIGIN
def generate_registration_challenge(
self,
user_id: str,
username: str,
display_name: str
) -> Dict[str, Any]:
"""
Generate WebAuthn registration options
Returns PublicKeyCredentialCreationOptions
"""
# Generate options using py_webauthn
options = generate_registration_options(
rp_id=self.rp_id,
rp_name=self.rp_name,
user_id=user_id.encode('utf-8'),
user_name=username,
user_display_name=display_name,
authenticator_selection=AuthenticatorSelectionCriteria(
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256, # -7
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, # -257
],
timeout=60000, # 60 seconds
)
# Convert to JSON-serializable dict
options_json = options_to_json(options)
options_dict = json.loads(options_json)
return {
"options": options_dict,
"challenge": base64.urlsafe_b64encode(options.challenge).decode('utf-8').rstrip('=')
}
def verify_registration(
self,
credential: Dict[str, Any],
expected_challenge: bytes,
expected_origin: str,
expected_rp_id: str
) -> Dict[str, Any]:
"""
Verify WebAuthn registration response
Returns verified credential data
"""
try:
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_origin=expected_origin,
expected_rp_id=expected_rp_id,
)
return {
"verified": True,
"credential_id": base64.urlsafe_b64encode(verification.credential_id).decode('utf-8').rstrip('='),
"public_key": base64.urlsafe_b64encode(verification.credential_public_key).decode('utf-8').rstrip('='),
"sign_count": verification.sign_count,
"aaguid": verification.aaguid.hex() if verification.aaguid else None,
"attestation_format": verification.fmt,
}
except Exception as e:
return {
"verified": False,
"error": str(e)
}
def generate_authentication_challenge(
self,
credentials: list[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Generate WebAuthn authentication options
credentials: list of user's passkeys with credential_id
"""
# Convert stored credentials to PublicKeyCredentialDescriptor
allow_credentials = [
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(cred["credential_id"] + "=="),
transports=cred.get("transports", [])
)
for cred in credentials
]
options = generate_authentication_options(
rp_id=self.rp_id,
allow_credentials=allow_credentials if allow_credentials else None,
user_verification=UserVerificationRequirement.PREFERRED,
timeout=60000,
)
# Convert to JSON-serializable dict
options_json = options_to_json(options)
options_dict = json.loads(options_json)
return {
"options": options_dict,
"challenge": base64.urlsafe_b64encode(options.challenge).decode('utf-8').rstrip('=')
}
def verify_authentication(
self,
credential: Dict[str, Any],
expected_challenge: bytes,
credential_public_key: bytes,
credential_current_sign_count: int,
expected_origin: str,
expected_rp_id: str
) -> Dict[str, Any]:
"""
Verify WebAuthn authentication response
Returns verification result with new sign count
"""
try:
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=expected_rp_id,
expected_origin=expected_origin,
credential_public_key=credential_public_key,
credential_current_sign_count=credential_current_sign_count,
)
return {
"verified": True,
"new_sign_count": verification.new_sign_count
}
except Exception as e:
return {
"verified": False,
"error": str(e)
}
# Global instance
webauthn_manager = WebAuthnManager()
# ============================================================================
# Helper Functions
# ============================================================================
def generate_challenge() -> str:
"""Generate a cryptographically secure random challenge"""
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
def generate_session_token() -> str:
"""Generate a secure session token"""
return secrets.token_urlsafe(32)
def hash_credential_id(credential_id: str) -> str:
"""Hash credential ID for storage"""
return hashlib.sha256(credential_id.encode()).hexdigest()

View File

@@ -0,0 +1,145 @@
"""
Shared Auth Middleware for DAARION Services
Use this in agents-service, microdao-service, city-service, secondme-service
"""
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional, List
from jose import jwt, JWTError
import os
# JWT Configuration - must match auth-service
JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "your-very-long-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
# Security scheme
security = HTTPBearer(auto_error=False)
class AuthUser:
"""Authenticated user context"""
def __init__(
self,
user_id: str,
email: str,
display_name: Optional[str],
roles: List[str]
):
self.user_id = user_id
self.email = email
self.display_name = display_name
self.roles = roles
def has_role(self, role: str) -> bool:
return role in self.roles
def is_admin(self) -> bool:
return "admin" in self.roles
def __repr__(self):
return f"AuthUser(user_id={self.user_id}, email={self.email}, roles={self.roles})"
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token"""
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"verify_exp": True}
)
# Verify it's an access token
if payload.get("type") != "access":
return None
return payload
except JWTError:
return None
async def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> Optional[AuthUser]:
"""
Get current user if authenticated, None otherwise.
Use this for endpoints that work both with and without auth.
"""
if not credentials:
return None
payload = decode_token(credentials.credentials)
if not payload:
return None
return AuthUser(
user_id=payload.get("sub"),
email=payload.get("email"),
display_name=payload.get("name"),
roles=payload.get("roles", [])
)
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> AuthUser:
"""
Get current user, raise 401 if not authenticated.
Use this for protected endpoints.
"""
if not credentials:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"}
)
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=401,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"}
)
return AuthUser(
user_id=payload.get("sub"),
email=payload.get("email"),
display_name=payload.get("name"),
roles=payload.get("roles", [])
)
def require_role(role: str):
"""
Dependency that requires a specific role.
Usage: @app.get("/admin", dependencies=[Depends(require_role("admin"))])
"""
async def role_checker(user: AuthUser = Depends(get_current_user)):
if not user.has_role(role):
raise HTTPException(
status_code=403,
detail=f"Role '{role}' required"
)
return user
return role_checker
def require_any_role(roles: List[str]):
"""
Dependency that requires any of the specified roles.
"""
async def role_checker(user: AuthUser = Depends(get_current_user)):
if not any(user.has_role(r) for r in roles):
raise HTTPException(
status_code=403,
detail=f"One of roles {roles} required"
)
return user
return role_checker
# Convenience aliases
RequireAuth = Depends(get_current_user)
OptionalAuth = Depends(get_current_user_optional)
RequireAdmin = Depends(require_role("admin"))