diff --git a/docker-compose.broker.yml b/docker-compose.broker.yml new file mode 100644 index 0000000..25adb38 --- /dev/null +++ b/docker-compose.broker.yml @@ -0,0 +1,32 @@ +# Token Broker Infrastructure +# PostgreSQL dedicated instance on port 5434 +networks: + turf-net: + driver: bridge + +services: + token-broker-db: + image: postgres:16-alpine + container_name: token-broker-db + restart: unless-stopped + environment: + POSTGRES_DB: token_broker + POSTGRES_USER: token_broker + POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD} + volumes: + - token-broker-pgdata:/var/lib/postgresql/data + - ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - turf-net + ports: + - "127.0.0.1:5434:5432" + +volumes: + token-broker-pgdata: + driver: local diff --git a/infra/postgres/token_broker_init.sql b/infra/postgres/token_broker_init.sql new file mode 100644 index 0000000..ff90d2b --- /dev/null +++ b/infra/postgres/token_broker_init.sql @@ -0,0 +1,94 @@ +-- Token Broker PostgreSQL init script +-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS api_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT 'default', + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + scopes TEXT[] DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + metadata JSONB DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + revoked_at TIMESTAMPTZ, + replaced_by UUID +); + +CREATE TABLE IF NOT EXISTS token_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER, + action TEXT NOT NULL, + token_prefix TEXT, + ip_address TEXT, + user_agent TEXT, + details JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id TEXT NOT NULL UNIQUE, + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + redirect_uris TEXT[] DEFAULT '{}', + scopes TEXT[] DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + provider_type TEXT NOT NULL DEFAULT 'oauth2', + issuer_url TEXT, + client_id TEXT, + client_secret TEXT, + scopes TEXT[] DEFAULT '{}', + config JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS token_usage ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + token_id UUID, + action TEXT NOT NULL DEFAULT 'verify', + endpoint TEXT, + status TEXT NOT NULL DEFAULT 'success', + response_time_ms INTEGER, + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at); +CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id); +CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name); +CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id); +CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at); + +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker; diff --git a/infra/scripts/deploy_token_broker.sh b/infra/scripts/deploy_token_broker.sh new file mode 100755 index 0000000..05624aa --- /dev/null +++ b/infra/scripts/deploy_token_broker.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# ============================================================ +# Deploy Token Broker — systemd service + Docker PG +# ============================================================ +set -euo pipefail + +APP_DIR="/home/h3r7/turf_saas" +SERVICE_NAME="token-broker" +PID_FILE="/tmp/token_broker.pid" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +echo "[$(date -Iseconds)] === Deploying Token Broker ===" + +# Step 1: Backup current code +echo "[$(date -Iseconds)] Backing up current code..." +mkdir -p /home/h3r7/backups/token-broker +cp "${APP_DIR}/services/token-broker/token_broker_api.py" \ + "/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py" + +# Step 2: Ensure Docker PG is running +echo "[$(date -Iseconds)] Ensuring PostgreSQL container..." +if ! docker inspect token-broker-db >/dev/null 2>&1; then + echo "Creating PG container..." + docker run -d \ + --name token-broker-db \ + --restart unless-stopped \ + -e POSTGRES_DB=token_broker \ + -e POSTGRES_USER=token_broker \ + -e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \ + -v token-broker-pgdata:/var/lib/postgresql/data \ + -v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \ + -p 127.0.0.1:5434:5432 \ + postgres:16-alpine +elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then + echo "Starting existing PG container..." + docker start token-broker-db +else + echo "PG container already running." +fi + +# Wait for PG readiness +echo "[$(date -Iseconds)] Waiting for PG to be ready..." +for i in $(seq 1 20); do + if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then + echo "PG ready." + break + fi + sleep 2 +done + +# Step 3: Ensure psycopg2-binary is installed +echo "[$(date -Iseconds)] Checking Python deps..." +source "${APP_DIR}/venv/bin/activate" +pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true + +# Step 4: Stop current service +echo "[$(date -Iseconds)] Stopping current service..." +if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then + systemctl stop ${SERVICE_NAME} +elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then + kill $(cat "$PID_FILE") 2>/dev/null || true +fi +sleep 2 + +# Step 5: Copy systemd unit and start +echo "[$(date -Iseconds)] Starting via systemd..." +cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable ${SERVICE_NAME} +systemctl start ${SERVICE_NAME} + +# Wait for startup +sleep 3 + +# Step 6: Health check +echo "[$(date -Iseconds)] Running health check..." +HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}') +STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown") + +if [ "$STATUS" = "ok" ]; then + echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}" + echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ===" +else + echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}" + echo "[$(date -Iseconds)] === Token Broker deploy FAILED ===" + exit 1 +fi + +# Step 7: Clean old backups (keep last 30) +find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete diff --git a/services/token-broker/.env.example b/services/token-broker/.env.example new file mode 100644 index 0000000..6863dd0 --- /dev/null +++ b/services/token-broker/.env.example @@ -0,0 +1,10 @@ +# Token Broker API — Configuration +TOKEN_BROKER_PORT=8783 +TOKEN_BROKER_DB_HOST=127.0.0.1 +TOKEN_BROKER_DB_PORT=5434 +TOKEN_BROKER_DB_NAME=token_broker +TOKEN_BROKER_DB_USER=token_broker +TOKEN_BROKER_DB_PASSWORD=CHANGE_ME +TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX +TOKEN_BROKER_ACCESS_EXPIRY=900 +TOKEN_BROKER_REFRESH_EXPIRY=2592000 diff --git a/services/token-broker/requirements.txt b/services/token-broker/requirements.txt new file mode 100644 index 0000000..15aaa46 --- /dev/null +++ b/services/token-broker/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.1.3 +flask-cors==5.0.1 +gunicorn==23.0.0 +psycopg2-binary==2.9.12 +PyJWT==2.10.1 +python-dotenv==1.1.0 diff --git a/services/token-broker/token-broker.service b/services/token-broker/token-broker.service new file mode 100644 index 0000000..7114113 --- /dev/null +++ b/services/token-broker/token-broker.service @@ -0,0 +1,21 @@ +[Unit] +Description=Token Broker API (Port 8783) +Documentation=https://portal-kolifee.duckdns.org +After=network.target postgresql.service + +[Service] +Type=simple +User=h3r7 +WorkingDirectory=/home/h3r7/turf_saas/services/token-broker + +EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env +Environment=PYTHONPATH=/home/h3r7/turf_saas +Environment=FLASK_ENV=production + +ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py + +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/services/token-broker/token_broker_api.py b/services/token-broker/token_broker_api.py new file mode 100644 index 0000000..c3f2f23 --- /dev/null +++ b/services/token-broker/token_broker_api.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +""" +Token Broker API — JWT token management service +Port: 8783 | DB: PostgreSQL 5434 +HRT-198 — Setup infra (PostgreSQL + Flask scaffold) + +Endpoints: + GET /health — Healthcheck + POST /api/v1/tokens — Issue new token (create) + GET /api/v1/tokens/:id — Get token by ID + POST /api/v1/tokens/verify — Verify token + POST /api/v1/tokens/revoke/:id — Revoke token + GET /api/v1/tokens/user/:userId — List tokens for user +""" + +import os +import sys +import uuid +import hashlib +import secrets +import logging +import logging.handlers +from datetime import datetime, timedelta, timezone +from functools import wraps + +from flask import Flask, request, jsonify, g +from flask_cors import CORS + +LOG_DIR = os.path.join(os.path.dirname(__file__), "logs") +os.makedirs(LOG_DIR, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] token-broker: %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.handlers.RotatingFileHandler( + os.path.join(LOG_DIR, "token_broker.log"), + maxBytes=5 * 1024 * 1024, + backupCount=3, + ), + ], +) +logger = logging.getLogger("token_broker") + +DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1") +DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434")) +DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker") +DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker") +DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "") +JWT_SECRET = os.environ.get( + "TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32) +) +ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900")) +REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000")) + + +def get_pg_conn(): + try: + import psycopg2 + import psycopg2.extras + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + ) + conn.autocommit = True + return conn + except Exception as e: + logger.error(f"PostgreSQL connection failed: {e}") + return None + + +def init_db(): + conn = get_pg_conn() + if not conn: + logger.error("Cannot initialize DB — no connection") + return False + try: + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS api_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT 'default', + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + scopes TEXT[] DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + metadata JSONB DEFAULT '{}' + ); + + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + revoked_at TIMESTAMPTZ, + replaced_by UUID + ); + + CREATE TABLE IF NOT EXISTS token_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER, + action TEXT NOT NULL, + token_prefix TEXT, + ip_address TEXT, + user_agent TEXT, + details JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id TEXT NOT NULL UNIQUE, + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + redirect_uris TEXT[] DEFAULT '{}', + scopes TEXT[] DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + provider_type TEXT NOT NULL DEFAULT 'oauth2', + issuer_url TEXT, + client_id TEXT, + client_secret TEXT, + scopes TEXT[] DEFAULT '{}', + config JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS token_usage ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + token_id UUID, + action TEXT NOT NULL DEFAULT 'verify', + endpoint TEXT, + status TEXT NOT NULL DEFAULT 'success', + response_time_ms INTEGER, + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id); + CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at); + CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id); + CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name); + CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id); + CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at); + """) + cur.close() + conn.close() + logger.info("Database tables initialized successfully") + return True + except Exception as e: + logger.error(f"Database initialization failed: {e}") + return False + + +def create_app(): + app = Flask(__name__) + + app.config["JWT_SECRET"] = JWT_SECRET + app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY + app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY + + CORS(app) + register_routes(app) + register_error_handlers(app) + + return app + + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401 + token = auth_header.split(" ", 1)[1] + payload = verify_jwt_token(token) + if not payload: + return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401 + g.user_id = payload.get("user_id") + g.token_id = payload.get("token_id") + g.scopes = payload.get("scopes", []) + return f(*args, **kwargs) + return decorated + + +def generate_token_pair(user_id, scopes=None, metadata=None): + import jwt as pyjwt + now = datetime.now(timezone.utc) + + access_payload = { + "user_id": user_id, + "token_id": str(uuid.uuid4()), + "scopes": scopes or [], + "type": "access", + "iat": now, + "exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY), + } + access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256") + + refresh_id = str(uuid.uuid4()) + refresh_raw = secrets.token_urlsafe(48) + refresh_payload = { + "user_id": user_id, + "refresh_id": refresh_id, + "token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(), + "type": "refresh", + "iat": now, + "exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY), + } + refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256") + + store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"]) + log_audit(user_id, "token_issued", access_payload["token_id"][:8]) + + return { + "access_token": access_token, + "refresh_token": refresh_raw, + "expires_in": ACCESS_TOKEN_EXPIRY, + "token_type": "Bearer", + } + + +def verify_jwt_token(token): + import jwt as pyjwt + try: + payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + if payload.get("type") == "refresh": + token_hash = hashlib.sha256(token.encode()).hexdigest() + conn = get_pg_conn() + if conn: + cur = conn.cursor() + cur.execute( + "SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()", + (token_hash,), + ) + row = cur.fetchone() + cur.close() + conn.close() + if not row or row[0]: + return None + return payload + except Exception: + return None + + +def store_refresh_token(user_id, refresh_id, token_hash): + conn = get_pg_conn() + if not conn: + return + try: + cur = conn.cursor() + cur.execute( + """INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at) + VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""", + (refresh_id, user_id, token_hash, token_hash[:8]), + ) + cur.close() + conn.close() + except Exception as e: + logger.error(f"Failed to store refresh token: {e}") + + +def log_audit(user_id, action, token_prefix, details=None): + conn = get_pg_conn() + if not conn: + return + try: + cur = conn.cursor() + cur.execute( + """INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details) + VALUES (%s, %s, %s, %s, %s, %s)""", + ( + user_id, + action, + token_prefix, + request.remote_addr if request else None, + request.user_agent.string if request and request.user_agent else None, + "{}" if details is None else details, + ), + ) + cur.close() + conn.close() + except Exception: + pass + + +def register_routes(app): + @app.route("/health", methods=["GET"]) + def healthcheck(): + conn = get_pg_conn() + db_ok = conn is not None + if conn: + conn.close() + return jsonify({ + "status": "ok" if db_ok else "degraded", + "service": "token-broker", + "version": "1.0.0", + "database": "connected" if db_ok else "disconnected", + "timestamp": datetime.now(timezone.utc).isoformat(), + }), 200 if db_ok else 503 + + @app.route("/api/v1/tokens", methods=["POST"]) + @token_required + def issue_token(): + data = request.get_json(silent=True) or {} + user_id = g.user_id + scopes = data.get("scopes", []) + name = data.get("name", "default") + metadata = data.get("metadata", {}) + + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error", "message": "Database unavailable"}), 503 + + try: + cur = conn.cursor() + import psycopg2.extras + raw_token = "tb_" + secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + token_prefix = raw_token[:12] + "..." + + cur.execute( + """INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, created_at, expires_at""", + (user_id, name, token_hash, token_prefix, scopes, + psycopg2.extras.Json(metadata)), + ) + row = cur.fetchone() + cur.close() + conn.close() + + log_audit(user_id, "api_token_created", token_prefix) + return jsonify({ + "id": str(row[0]), + "token": raw_token, + "name": name, + "scopes": scopes, + "created_at": row[1].isoformat(), + "expires_at": row[2].isoformat() if row[2] else None, + }), 201 + except Exception as e: + logger.error(f"Token creation failed: {e}") + return jsonify({"error": "creation_failed", "message": str(e)}), 500 + + @app.route("/api/v1/tokens/verify", methods=["POST"]) + def verify_token(): + data = request.get_json(silent=True) or {} + raw_token = data.get("token", "") + + if not raw_token: + return jsonify({"valid": False, "error": "token_required"}), 400 + + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + conn = get_pg_conn() + if not conn: + return jsonify({"valid": False, "error": "db_error"}), 503 + + try: + cur = conn.cursor() + cur.execute( + """SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at + FROM api_tokens + WHERE token_hash = %s""", + (token_hash,), + ) + row = cur.fetchone() + + if not row: + cur.close() + conn.close() + return jsonify({"valid": False, "error": "token_not_found"}), 404 + + token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row + + if not is_active: + cur.close() + conn.close() + return jsonify({"valid": False, "error": "token_revoked"}), 403 + + if expires_at and expires_at < datetime.now(timezone.utc): + cur.close() + conn.close() + return jsonify({"valid": False, "error": "token_expired"}), 403 + + cur.execute( + "UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s", + (token_id,), + ) + cur.close() + conn.close() + + return jsonify({ + "valid": True, + "token_id": str(token_id), + "user_id": user_id, + "name": name, + "scopes": scopes, + }) + except Exception as e: + logger.error(f"Token verification failed: {e}") + return jsonify({"valid": False, "error": "verification_failed"}), 500 + + @app.route("/api/v1/tokens/", methods=["GET"]) + @token_required + def get_token(token_id): + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + try: + cur = conn.cursor() + cur.execute( + """SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata + FROM api_tokens WHERE id = %s AND user_id = %s""", + (token_id, g.user_id), + ) + row = cur.fetchone() + cur.close() + conn.close() + + if not row: + return jsonify({"error": "not_found"}), 404 + + return jsonify({ + "id": str(row[0]), + "user_id": row[1], + "name": row[2], + "scopes": row[3], + "is_active": row[4], + "created_at": row[5].isoformat(), + "expires_at": row[6].isoformat() if row[6] else None, + "last_used_at": row[7].isoformat() if row[7] else None, + "metadata": row[8] if row[8] else {}, + }) + except Exception as e: + logger.error(f"Get token failed: {e}") + return jsonify({"error": "query_failed"}), 500 + + @app.route("/api/v1/tokens/revoke/", methods=["POST"]) + @token_required + def revoke_token(token_id): + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + try: + cur = conn.cursor() + cur.execute( + """UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s + RETURNING id, name""", + (token_id, g.user_id), + ) + row = cur.fetchone() + cur.close() + conn.close() + + if not row: + return jsonify({"error": "not_found"}), 404 + + log_audit(g.user_id, "api_token_revoked", str(row[0])[:8]) + return jsonify({"status": "revoked", "token_id": str(row[0])}) + except Exception as e: + logger.error(f"Revoke token failed: {e}") + return jsonify({"error": "revoke_failed"}), 500 + + @app.route("/api/v1/tokens/user/", methods=["GET"]) + @token_required + def list_user_tokens(user_id): + if g.user_id != user_id and "admin" not in g.scopes: + return jsonify({"error": "forbidden"}), 403 + + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + try: + cur = conn.cursor() + cur.execute( + """SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at + FROM api_tokens + WHERE user_id = %s + ORDER BY created_at DESC""", + (user_id,), + ) + rows = cur.fetchall() + cur.close() + conn.close() + + tokens = [] + for row in rows: + tokens.append({ + "id": str(row[0]), + "user_id": row[1], + "name": row[2], + "scopes": row[3], + "is_active": row[4], + "created_at": row[5].isoformat(), + "expires_at": row[6].isoformat() if row[6] else None, + "last_used_at": row[7].isoformat() if row[7] else None, + }) + return jsonify({"tokens": tokens, "total": len(tokens)}) + except Exception as e: + logger.error(f"List tokens failed: {e}") + return jsonify({"error": "query_failed"}), 500 + + @app.route("/api/v1/auth/token", methods=["POST"]) + def exchange_token(): + data = request.get_json(silent=True) or {} + grant_type = data.get("grant_type", "client_credentials") + raw_token = data.get("client_token", "") or data.get("token", "") + refresh_raw = data.get("refresh_token", "") + + if grant_type == "refresh_token" and refresh_raw: + return refresh_access_token(refresh_raw) + + if not raw_token: + return jsonify({"error": "invalid_request", "message": "client_token required"}), 400 + + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + + try: + cur = conn.cursor() + cur.execute( + """SELECT id, user_id, scopes, is_active, expires_at + FROM api_tokens WHERE token_hash = %s""", + (token_hash,), + ) + row = cur.fetchone() + cur.close() + conn.close() + + if not row: + return jsonify({"error": "invalid_token"}), 401 + if not row[3]: + return jsonify({"error": "token_revoked"}), 403 + if row[4] and row[4] < datetime.now(timezone.utc): + return jsonify({"error": "token_expired"}), 403 + + token_pair = generate_token_pair(row[1], row[2]) + return jsonify(token_pair), 200 + except Exception as e: + logger.error(f"Token exchange failed: {e}") + return jsonify({"error": "exchange_failed"}), 500 + + @app.route("/api/v1/auth/refresh", methods=["POST"]) + def refresh_token_endpoint(): + data = request.get_json(silent=True) or {} + refresh_raw = data.get("refresh_token", "") + return refresh_access_token(refresh_raw) + + @app.route("/api/v1/auth/revoke", methods=["POST"]) + @token_required + def revoke_refresh_token(): + data = request.get_json(silent=True) or {} + refresh_raw = data.get("refresh_token", "") + + if not refresh_raw: + return jsonify({"error": "refresh_token_required"}), 400 + + token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest() + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + try: + cur = conn.cursor() + cur.execute( + "UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s", + (token_hash,), + ) + cur.close() + conn.close() + log_audit(g.user_id, "refresh_token_revoked", token_hash[:8]) + return jsonify({"status": "revoked"}) + except Exception as e: + logger.error(f"Revoke refresh token failed: {e}") + return jsonify({"error": "revoke_failed"}), 500 + + +def refresh_access_token(refresh_raw): + if not refresh_raw: + return jsonify({"error": "refresh_token_required"}), 400 + + token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest() + conn = get_pg_conn() + if not conn: + return jsonify({"error": "db_error"}), 503 + try: + cur = conn.cursor() + cur.execute( + """SELECT id, user_id, revoked, expires_at + FROM refresh_tokens WHERE token_hash = %s""", + (token_hash,), + ) + row = cur.fetchone() + + if not row: + cur.close() + conn.close() + return jsonify({"error": "invalid_token"}), 401 + if row[2]: + cur.close() + conn.close() + return jsonify({"error": "token_revoked"}), 403 + if row[3] < datetime.now(timezone.utc): + cur.close() + conn.close() + return jsonify({"error": "token_expired"}), 403 + + refresh_id = row[0] + user_id = row[1] + + cur.execute( + "UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s", + (refresh_id,), + ) + + pairs = generate_token_pair(user_id) + cur.close() + conn.close() + return jsonify(pairs), 200 + except Exception as e: + logger.error(f"Refresh token failed: {e}") + return jsonify({"error": "refresh_failed"}), 500 + + +def register_error_handlers(app): + @app.errorhandler(404) + def not_found(e): + return jsonify({"error": "not_found", "message": "Route not found"}), 404 + + @app.errorhandler(405) + def method_not_allowed(e): + return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405 + + @app.errorhandler(500) + def internal_error(e): + logger.error(f"Internal error: {e}") + return jsonify({"error": "internal_error", "message": "Internal server error"}), 500 + + +if __name__ == "__main__": + logger.info("=" * 60) + logger.info("Token Broker API starting...") + logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}") + logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}") + logger.info("=" * 60) + + init_db() + + port = int(os.environ.get("TOKEN_BROKER_PORT", "8783")) + debug = os.environ.get("FLASK_ENV", "production") == "development" + app = create_app() + app.run(host="0.0.0.0", port=port, debug=debug)