diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
new file mode 100644
index 00000000..5b895a7b
--- /dev/null
+++ b/apps/web/src/app/(auth)/login/page.tsx
@@ -0,0 +1,163 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { Mail, Lock, Loader2, Sparkles, AlertCircle } from 'lucide-react'
+import { useAuth } from '@/context/AuthContext'
+import { cn } from '@/lib/utils'
+
+export default function LoginPage() {
+ const router = useRouter()
+ const { login, isAuthenticated } = useAuth()
+
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ // Redirect if already authenticated
+ if (isAuthenticated) {
+ router.push('/')
+ return null
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ try {
+ await login(email, password)
+ router.push('/')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Login failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+ DAARION
+
+
+
Вхід в акаунт
+
+ Увійдіть, щоб продовжити у DAARION.city
+
+
+
+ {/* Form */}
+
+
+
+ {/* Divider */}
+
+
+ {/* Register link */}
+
+ Немає акаунту?{' '}
+
+ Зареєструватися
+
+
+
+
+ {/* Back to home */}
+
+
+ ← Повернутися на головну
+
+
+
+
+ )
+}
+
diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx
new file mode 100644
index 00000000..802ab172
--- /dev/null
+++ b/apps/web/src/app/(auth)/register/page.tsx
@@ -0,0 +1,256 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { Mail, Lock, User, Loader2, Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react'
+import { useAuth } from '@/context/AuthContext'
+import { cn } from '@/lib/utils'
+
+export default function RegisterPage() {
+ const router = useRouter()
+ const { register, isAuthenticated } = useAuth()
+
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ // Redirect if already authenticated
+ if (isAuthenticated) {
+ router.push('/')
+ return null
+ }
+
+ const passwordRequirements = [
+ { met: password.length >= 8, text: 'Мінімум 8 символів' },
+ { met: /[A-Z]/.test(password), text: 'Одна велика літера' },
+ { met: /[a-z]/.test(password), text: 'Одна мала літера' },
+ { met: /[0-9]/.test(password), text: 'Одна цифра' },
+ ]
+
+ const isPasswordValid = passwordRequirements.every(r => r.met)
+ const doPasswordsMatch = password === confirmPassword && password.length > 0
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+
+ if (!isPasswordValid) {
+ setError('Пароль не відповідає вимогам')
+ return
+ }
+
+ if (!doPasswordsMatch) {
+ setError('Паролі не співпадають')
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ await register(email, password, displayName || undefined)
+ router.push('/')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Registration failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+
+ DAARION
+
+
+
Створити акаунт
+
+ Приєднуйтесь до DAARION.city
+
+
+
+ {/* Form */}
+
+
+
+ {/* Divider */}
+
+
+ {/* Login link */}
+
+ Вже маєте акаунт?{' '}
+
+ Увійти
+
+
+
+
+ {/* Back to home */}
+
+
+ ← Повернутися на головну
+
+
+
+
+ )
+}
+
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 5edabc40..856f7a70 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Navigation } from '@/components/Navigation'
+import { AuthProvider } from '@/context/AuthContext'
const inter = Inter({
subsets: ['latin', 'cyrillic'],
@@ -31,16 +32,18 @@ export default function RootLayout({
return (
- {/* Ambient background effect */}
-
-
- {/* Navigation */}
-
-
- {/* Main content */}
-
- {children}
-
+
+ {/* Ambient background effect */}
+
+
+ {/* Navigation */}
+
+
+ {/* Main content */}
+
+ {children}
+
+
)
diff --git a/apps/web/src/components/Navigation.tsx b/apps/web/src/components/Navigation.tsx
index 119d799c..1b32b131 100644
--- a/apps/web/src/components/Navigation.tsx
+++ b/apps/web/src/components/Navigation.tsx
@@ -2,9 +2,10 @@
import { useState } from 'react'
import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet } from 'lucide-react'
+import { usePathname, useRouter } from 'next/navigation'
+import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
+import { useAuth } from '@/context/AuthContext'
const navItems = [
{ href: '/', label: 'Головна', icon: Home },
@@ -17,6 +18,19 @@ const navItems = [
export function Navigation() {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
+ const router = useRouter()
+ const { user, isAuthenticated, isLoading, logout } = useAuth()
+
+ const handleLogout = async () => {
+ await logout()
+ router.push('/')
+ setIsOpen(false)
+ }
+
+ // Don't show navigation on auth pages
+ if (pathname.startsWith('/login') || pathname.startsWith('/register')) {
+ return null
+ }
return (
)
}
-
diff --git a/apps/web/src/context/AuthContext.tsx b/apps/web/src/context/AuthContext.tsx
new file mode 100644
index 00000000..06c4d171
--- /dev/null
+++ b/apps/web/src/context/AuthContext.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
+import { User, getStoredUser, getMe, login as authLogin, logout as authLogout, register as authRegister, isAuthenticated } from '@/lib/auth'
+
+interface AuthContextType {
+ user: User | null
+ isLoading: boolean
+ isAuthenticated: boolean
+ login: (email: string, password: string) => Promise
+ register: (email: string, password: string, displayName?: string) => Promise
+ logout: () => Promise
+ refreshUser: () => Promise
+}
+
+const AuthContext = createContext(undefined)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ const refreshUser = useCallback(async () => {
+ try {
+ const userData = await getMe()
+ setUser(userData)
+ } catch {
+ setUser(null)
+ }
+ }, [])
+
+ useEffect(() => {
+ // Check for stored user on mount
+ const storedUser = getStoredUser()
+ if (storedUser) {
+ setUser(storedUser)
+ }
+
+ // Verify token and refresh user data
+ if (isAuthenticated()) {
+ refreshUser().finally(() => setIsLoading(false))
+ } else {
+ setIsLoading(false)
+ }
+ }, [refreshUser])
+
+ const login = async (email: string, password: string) => {
+ const response = await authLogin(email, password)
+ setUser(response.user)
+ }
+
+ const register = async (email: string, password: string, displayName?: string) => {
+ await authRegister(email, password, displayName)
+ // Auto-login after registration
+ await login(email, password)
+ }
+
+ const logout = async () => {
+ await authLogout()
+ setUser(null)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider')
+ }
+ return context
+}
+
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
new file mode 100644
index 00000000..70be1fe3
--- /dev/null
+++ b/apps/web/src/lib/auth.ts
@@ -0,0 +1,228 @@
+'use client'
+
+/**
+ * Auth utilities for DAARION Frontend
+ */
+
+export interface User {
+ id: string
+ email: string
+ display_name: string | null
+ avatar_url: string | null
+ roles: string[]
+ is_active: boolean
+ created_at: string
+}
+
+export interface AuthTokens {
+ access_token: string
+ refresh_token: string
+ token_type: string
+ expires_in: number
+}
+
+export interface LoginResponse extends AuthTokens {
+ user: User
+}
+
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || ''
+
+// Token storage keys
+const ACCESS_TOKEN_KEY = 'daarion_access_token'
+const REFRESH_TOKEN_KEY = 'daarion_refresh_token'
+const USER_KEY = 'daarion_user'
+
+// Token management
+export function getAccessToken(): string | null {
+ if (typeof window === 'undefined') return null
+ return localStorage.getItem(ACCESS_TOKEN_KEY)
+}
+
+export function getRefreshToken(): string | null {
+ if (typeof window === 'undefined') return null
+ return localStorage.getItem(REFRESH_TOKEN_KEY)
+}
+
+export function getStoredUser(): User | null {
+ if (typeof window === 'undefined') return null
+ const stored = localStorage.getItem(USER_KEY)
+ if (!stored) return null
+ try {
+ return JSON.parse(stored)
+ } catch {
+ return null
+ }
+}
+
+export function setTokens(tokens: AuthTokens, user?: User): void {
+ if (typeof window === 'undefined') return
+ localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
+ localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
+ if (user) {
+ localStorage.setItem(USER_KEY, JSON.stringify(user))
+ }
+}
+
+export function clearTokens(): void {
+ if (typeof window === 'undefined') return
+ localStorage.removeItem(ACCESS_TOKEN_KEY)
+ localStorage.removeItem(REFRESH_TOKEN_KEY)
+ localStorage.removeItem(USER_KEY)
+}
+
+export function isAuthenticated(): boolean {
+ return !!getAccessToken()
+}
+
+// API calls
+export async function register(
+ email: string,
+ password: string,
+ displayName?: string
+): Promise<{ user_id: string; email: string }> {
+ const response = await fetch(`${API_BASE}/api/auth/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email,
+ password,
+ display_name: displayName
+ })
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || 'Registration failed')
+ }
+
+ return response.json()
+}
+
+export async function login(email: string, password: string): Promise {
+ const response = await fetch(`${API_BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password })
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || 'Login failed')
+ }
+
+ const data: LoginResponse = await response.json()
+ setTokens(data, data.user)
+ return data
+}
+
+export async function refreshTokens(): Promise {
+ const refreshToken = getRefreshToken()
+ if (!refreshToken) return null
+
+ try {
+ const response = await fetch(`${API_BASE}/api/auth/refresh`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: refreshToken })
+ })
+
+ if (!response.ok) {
+ clearTokens()
+ return null
+ }
+
+ const data: AuthTokens = await response.json()
+ setTokens(data)
+ return data
+ } catch {
+ clearTokens()
+ return null
+ }
+}
+
+export async function logout(): Promise {
+ const refreshToken = getRefreshToken()
+
+ if (refreshToken) {
+ try {
+ await fetch(`${API_BASE}/api/auth/logout`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: refreshToken })
+ })
+ } catch {
+ // Ignore errors during logout
+ }
+ }
+
+ clearTokens()
+}
+
+export async function getMe(): Promise {
+ const token = getAccessToken()
+ if (!token) return null
+
+ try {
+ const response = await fetch(`${API_BASE}/api/auth/me`, {
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ })
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ // Try to refresh
+ const refreshed = await refreshTokens()
+ if (refreshed) {
+ return getMe()
+ }
+ clearTokens()
+ }
+ return null
+ }
+
+ const user: User = await response.json()
+ localStorage.setItem(USER_KEY, JSON.stringify(user))
+ return user
+ } catch {
+ return null
+ }
+}
+
+// Auth header for API requests
+export function getAuthHeader(): Record {
+ const token = getAccessToken()
+ if (!token) return {}
+ return { 'Authorization': `Bearer ${token}` }
+}
+
+// Fetch with auth
+export async function authFetch(
+ url: string,
+ options: RequestInit = {}
+): Promise {
+ const token = getAccessToken()
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(options.headers as Record || {})
+ }
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+
+ let response = await fetch(url, { ...options, headers })
+
+ // If 401, try to refresh and retry
+ if (response.status === 401 && token) {
+ const refreshed = await refreshTokens()
+ if (refreshed) {
+ headers['Authorization'] = `Bearer ${refreshed.access_token}`
+ response = await fetch(url, { ...options, headers })
+ }
+ }
+
+ return response
+}
+
diff --git a/docs/security/AUTH_SPEC.md b/docs/security/AUTH_SPEC.md
new file mode 100644
index 00000000..f33a2acb
--- /dev/null
+++ b/docs/security/AUTH_SPEC.md
@@ -0,0 +1,349 @@
+# AUTH SPEC — DAARION.city
+Version: 1.0.0
+
+---
+
+## 0. PURPOSE
+
+Цей документ описує базову систему автентифікації та авторизації для DAARION.city:
+
+- єдину модель користувача (`user_id`) для:
+ - фронтенду (web/PWA),
+ - Matrix/chat інтеграції,
+ - MicroDAO governance,
+ - Agents Service,
+ - SecondMe.
+- механізм логіну/логауту (JWT access + refresh tokens),
+- базову RBAC (roles/permissions),
+- інтеграцію з існуючими сервісами (agents, microdao, city, secondme).
+
+Фокус цієї версії — **MVP-рівень**:
+
+- Password-based login (email + password) + готовність до OAuth (Google/Telegram) як наступний крок.
+- JWT токени (access + refresh).
+- Мінімальний набір ролей (`user`, `admin`, `agent-system`).
+- Захист основних API (governance, agents, secondme private).
+
+---
+
+## 1. ARCHITECTURE OVERVIEW
+
+### 1.1. Auth Service
+
+Окремий сервіс `auth-service` (порт: **7020**):
+
+```text
+[ Web / PWA / Matrix Gateway ]
+ ↓
+ [ Auth Service (7020) ]
+ ↓
+[ PostgreSQL (auth tables) + Redis (sessions cache) ]
+ ↓
+[ JWT токени для інших сервісів ]
+```
+
+Auth Service:
+
+* реєструє користувачів,
+* зберігає хеші паролів,
+* видає JWT access/refresh токени,
+* перевіряє токени (через shared secret / public key),
+* надає API для інших сервісів (`/auth/introspect`).
+
+### 1.2. Інші сервіси
+
+* `Agents Service`, `MicroDAO Service`, `SecondMe`, `City Service`:
+ * отримують JWT у `Authorization: Bearer `,
+ * валідують його (прямо або через Auth Service),
+ * витягують `user_id`, `roles`, `scopes`.
+
+---
+
+## 2. DATA MODEL (PostgreSQL)
+
+### 2.1. auth_users
+
+```sql
+CREATE TABLE auth_users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ display_name TEXT,
+ avatar_url TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ locale TEXT DEFAULT 'uk',
+ timezone TEXT DEFAULT 'Europe/Kyiv',
+ meta JSONB DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX ix_auth_users_email ON auth_users(email);
+```
+
+### 2.2. auth_roles
+
+```sql
+CREATE TABLE auth_roles (
+ id TEXT PRIMARY KEY, -- 'user' | 'admin' | 'agent-system'
+ description TEXT
+);
+
+INSERT INTO auth_roles (id, description) VALUES
+ ('user', 'Regular user'),
+ ('admin', 'Administrator'),
+ ('agent-system', 'System agent');
+```
+
+### 2.3. auth_user_roles
+
+```sql
+CREATE TABLE auth_user_roles (
+ user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
+ role_id TEXT NOT NULL REFERENCES auth_roles(id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, role_id)
+);
+```
+
+### 2.4. auth_sessions
+
+```sql
+CREATE TABLE auth_sessions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
+ user_agent TEXT,
+ ip_address INET,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ revoked_at TIMESTAMPTZ,
+ meta JSONB DEFAULT '{}'::jsonb
+);
+
+CREATE INDEX ix_auth_sessions_user_id ON auth_sessions(user_id);
+CREATE INDEX ix_auth_sessions_expires ON auth_sessions(expires_at);
+```
+
+---
+
+## 3. TOKEN MODEL (JWT)
+
+### 3.1. Access token
+
+* Формат: JWT (HS256).
+* Термін дії: 30 хвилин.
+* Payload:
+
+```json
+{
+ "sub": "user_id-uuid",
+ "email": "user@example.com",
+ "name": "Display Name",
+ "roles": ["user"],
+ "iat": 1732590000,
+ "exp": 1732591800,
+ "iss": "daarion-auth",
+ "type": "access"
+}
+```
+
+### 3.2. Refresh token
+
+* Формат: JWT (HS256).
+* Термін дії: 7 днів.
+* Payload:
+
+```json
+{
+ "sub": "user_id-uuid",
+ "session_id": "session-uuid",
+ "iat": 1732590000,
+ "exp": 1733194800,
+ "iss": "daarion-auth",
+ "type": "refresh"
+}
+```
+
+---
+
+## 4. HTTP API (PUBLIC)
+
+Базовий шлях: `/api/auth/...`.
+
+### 4.1. `POST /api/auth/register`
+
+**Request:**
+```json
+{
+ "email": "user@example.com",
+ "password": "StrongPassword123",
+ "display_name": "Alex"
+}
+```
+
+**Response (201):**
+```json
+{
+ "user_id": "uuid",
+ "email": "user@example.com",
+ "display_name": "Alex",
+ "roles": ["user"]
+}
+```
+
+### 4.2. `POST /api/auth/login`
+
+**Request:**
+```json
+{
+ "email": "user@example.com",
+ "password": "StrongPassword123"
+}
+```
+
+**Response (200):**
+```json
+{
+ "access_token": "",
+ "refresh_token": "",
+ "token_type": "Bearer",
+ "expires_in": 1800,
+ "user": {
+ "id": "uuid",
+ "email": "user@example.com",
+ "display_name": "Alex",
+ "roles": ["user"]
+ }
+}
+```
+
+### 4.3. `POST /api/auth/refresh`
+
+**Request:**
+```json
+{
+ "refresh_token": ""
+}
+```
+
+**Response (200):**
+```json
+{
+ "access_token": "",
+ "refresh_token": "",
+ "token_type": "Bearer",
+ "expires_in": 1800
+}
+```
+
+### 4.4. `POST /api/auth/logout`
+
+**Request:**
+```json
+{
+ "refresh_token": ""
+}
+```
+
+**Response:**
+```json
+{
+ "status": "ok"
+}
+```
+
+### 4.5. `GET /api/auth/me`
+
+**Headers:** `Authorization: Bearer `
+
+**Response (200):**
+```json
+{
+ "id": "uuid",
+ "email": "user@example.com",
+ "display_name": "Alex",
+ "avatar_url": null,
+ "roles": ["user"],
+ "created_at": "2025-11-26T10:00:00Z"
+}
+```
+
+---
+
+## 5. HTTP API (INTERNAL)
+
+### 5.1. `POST /api/auth/introspect`
+
+**Request:**
+```json
+{
+ "token": ""
+}
+```
+
+**Response (200, valid):**
+```json
+{
+ "active": true,
+ "sub": "user_id-uuid",
+ "email": "user@example.com",
+ "roles": ["user"],
+ "exp": 1732591800
+}
+```
+
+**Response (200, invalid):**
+```json
+{
+ "active": false
+}
+```
+
+---
+
+## 6. HEALTHCHECK
+
+### `GET /healthz`
+
+```json
+{
+ "status": "ok",
+ "service": "auth-service",
+ "version": "1.0.0"
+}
+```
+
+---
+
+## 7. CONFIGURATION (ENV)
+
+```env
+AUTH_SERVICE_PORT=7020
+AUTH_DB_DSN=postgresql://user:pass@postgres:5432/daarion
+AUTH_JWT_SECRET=your-very-long-secret-key-here
+AUTH_ACCESS_TOKEN_TTL=1800
+AUTH_REFRESH_TOKEN_TTL=604800
+AUTH_BCRYPT_ROUNDS=12
+```
+
+---
+
+## 8. SECURITY NOTES
+
+* Паролі зберігати тільки як `bcrypt` hash.
+* JWT secret — довгий (мінімум 32 символи), збережений у `.env`.
+* Rate limiting для `/auth/login` (захист від brute force).
+* Логи не повинні писати паролі / токени.
+* HTTPS обов'язковий у production.
+
+---
+
+## 9. ROADMAP (POST-MVP)
+
+* OAuth2 / OIDC (Google, GitHub, Telegram).
+* WebAuthn / passkeys.
+* Device-level identity (звʼязок із Matrix devices).
+* On-chain identity (wallet + DID).
+* Email verification.
+* Password reset flow.
+
diff --git a/migrations/011_create_auth_tables.sql b/migrations/011_create_auth_tables.sql
new file mode 100644
index 00000000..9cc41526
--- /dev/null
+++ b/migrations/011_create_auth_tables.sql
@@ -0,0 +1,72 @@
+-- Migration: 011_create_auth_tables.sql
+-- Auth system for DAARION.city
+
+-- Users table
+CREATE TABLE IF NOT EXISTS auth_users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ display_name TEXT,
+ avatar_url TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ locale TEXT DEFAULT 'uk',
+ timezone TEXT DEFAULT 'Europe/Kyiv',
+ meta JSONB DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS ix_auth_users_email ON auth_users(email);
+
+-- Roles table
+CREATE TABLE IF NOT EXISTS auth_roles (
+ id TEXT PRIMARY KEY,
+ description TEXT
+);
+
+-- Default roles
+INSERT INTO auth_roles (id, description) VALUES
+ ('user', 'Regular user'),
+ ('admin', 'Administrator'),
+ ('agent-system', 'System agent')
+ON CONFLICT (id) DO NOTHING;
+
+-- User-Role mapping
+CREATE TABLE IF NOT EXISTS auth_user_roles (
+ user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
+ role_id TEXT NOT NULL REFERENCES auth_roles(id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, role_id)
+);
+
+-- Sessions table for refresh tokens
+CREATE TABLE IF NOT EXISTS auth_sessions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
+ user_agent TEXT,
+ ip_address INET,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ revoked_at TIMESTAMPTZ,
+ meta JSONB DEFAULT '{}'::jsonb
+);
+
+CREATE INDEX IF NOT EXISTS ix_auth_sessions_user_id ON auth_sessions(user_id);
+CREATE INDEX IF NOT EXISTS ix_auth_sessions_expires ON auth_sessions(expires_at);
+
+-- Function to update updated_at timestamp
+CREATE OR REPLACE FUNCTION update_auth_users_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Trigger for updated_at
+DROP TRIGGER IF EXISTS trigger_auth_users_updated_at ON auth_users;
+CREATE TRIGGER trigger_auth_users_updated_at
+ BEFORE UPDATE ON auth_users
+ FOR EACH ROW
+ EXECUTE FUNCTION update_auth_users_updated_at();
+
diff --git a/services/auth-service/Dockerfile b/services/auth-service/Dockerfile
new file mode 100644
index 00000000..ba894100
--- /dev/null
+++ b/services/auth-service/Dockerfile
@@ -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"]
diff --git a/services/auth-service/README.md b/services/auth-service/README.md
new file mode 100644
index 00000000..3fb6fd62
--- /dev/null
+++ b/services/auth-service/README.md
@@ -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 "
+```
+
+### POST /auth/logout
+```bash
+curl -X POST http://localhost:7011/auth/logout \
+ -H "Authorization: Bearer "
+```
+
+### POST /auth/api-keys
+Create API key:
+```bash
+curl -X POST http://localhost:7011/auth/api-keys \
+ -H "Authorization: Bearer " \
+ -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 "
+```
+
+### DELETE /auth/api-keys/{key_id}
+```bash
+curl -X DELETE http://localhost:7011/auth/api-keys/key-123 \
+ -H "Authorization: Bearer "
+```
+
+## 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 ** (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
+
+
+
+
diff --git a/services/auth-service/actor_context.py b/services/auth-service/actor_context.py
new file mode 100644
index 00000000..bf3fb685
--- /dev/null
+++ b/services/auth-service/actor_context.py
@@ -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)
+
+
+
+
diff --git a/services/auth-service/config.py b/services/auth-service/config.py
new file mode 100644
index 00000000..f5c3fd0f
--- /dev/null
+++ b/services/auth-service/config.py
@@ -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()
+
diff --git a/services/auth-service/database.py b/services/auth-service/database.py
new file mode 100644
index 00000000..b3a3c2e2
--- /dev/null
+++ b/services/auth-service/database.py
@@ -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'
+ """
+ )
+
diff --git a/services/auth-service/main.py b/services/auth-service/main.py
new file mode 100644
index 00000000..236383dc
--- /dev/null
+++ b/services/auth-service/main.py
@@ -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
+ )
diff --git a/services/auth-service/models.py b/services/auth-service/models.py
new file mode 100644
index 00000000..72d8a455
--- /dev/null
+++ b/services/auth-service/models.py
@@ -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
diff --git a/services/auth-service/passkey_store.py b/services/auth-service/passkey_store.py
new file mode 100644
index 00000000..3b0637c0
--- /dev/null
+++ b/services/auth-service/passkey_store.py
@@ -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]
+
+
+
+
diff --git a/services/auth-service/requirements.txt b/services/auth-service/requirements.txt
new file mode 100644
index 00000000..66864c16
--- /dev/null
+++ b/services/auth-service/requirements.txt
@@ -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
diff --git a/services/auth-service/routes_api_keys.py b/services/auth-service/routes_api_keys.py
new file mode 100644
index 00000000..7d03421c
--- /dev/null
+++ b/services/auth-service/routes_api_keys.py
@@ -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}
+
+
+
+
diff --git a/services/auth-service/routes_passkey.py b/services/auth-service/routes_passkey.py
new file mode 100644
index 00000000..78b613bd
--- /dev/null
+++ b/services/auth-service/routes_passkey.py
@@ -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
+ )
+
+
+
+
diff --git a/services/auth-service/routes_sessions.py b/services/auth-service/routes_sessions.py
new file mode 100644
index 00000000..117d6700
--- /dev/null
+++ b/services/auth-service/routes_sessions.py
@@ -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"}
+
+
+
+
diff --git a/services/auth-service/security.py b/services/auth-service/security.py
new file mode 100644
index 00000000..5b6c42b2
--- /dev/null
+++ b/services/auth-service/security.py
@@ -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
+
diff --git a/services/auth-service/webauthn_utils.py b/services/auth-service/webauthn_utils.py
new file mode 100644
index 00000000..b1ff0770
--- /dev/null
+++ b/services/auth-service/webauthn_utils.py
@@ -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()
+
+
+
+
diff --git a/services/common/auth_middleware.py b/services/common/auth_middleware.py
new file mode 100644
index 00000000..51c40950
--- /dev/null
+++ b/services/common/auth_middleware.py
@@ -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"))
+