fix(api): support desktop app login and CORS

Allow the desktop renderer to authenticate through the app login endpoint and call API routes from its packaged origin without weakening non-API responses.
This commit is contained in:
Dries Peeters
2026-04-25 17:34:15 +02:00
parent bd5d4d0cc7
commit 443a6b87bf
2 changed files with 24 additions and 5 deletions
+19
View File
@@ -575,6 +575,16 @@ def create_app(config=None):
except Exception:
pass
@app.before_request
def handle_api_cors_preflight():
if request.method == "OPTIONS" and request.path.startswith("/api/v1/"):
response = app.response_class(status=204)
response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID"
response.headers["Access-Control-Max-Age"] = "600"
return response
# Start timer for Prometheus metrics
@app.before_request
def prom_start_timer():
@@ -746,6 +756,15 @@ def create_app(config=None):
# CSRF cookie/token handling
# If CSRF is enabled, ensure CSRF cookie exists for HTML GET responses
# If CSRF is disabled, explicitly clear any existing CSRF cookie to avoid confusion
try:
if request.path.startswith("/api/v1/"):
response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*"
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID"
except Exception:
pass
if app.config.get("WTF_CSRF_ENABLED"):
try:
# Only for safe, HTML page responses
+5 -5
View File
@@ -218,11 +218,11 @@ def health_check():
@api_v1_bp.route("/auth/login", methods=["POST"])
@limiter.limit("5 per minute", methods=["POST"])
def auth_login():
"""Login with username and password; returns an API token for mobile/app use.
"""Login with username and password; returns an API token for app use.
Accepts JSON: { "username": "...", "password": "..." }.
Returns 200 with { "token": "tt_..." } or 401 with { "error": "..." }.
The token has scopes for basics: read:projects, read:tasks, read:time_entries, write:time_entries.
Admin users receive admin scope; regular users receive broad read/write API scopes.
"""
current_app.logger.info(
"POST /api/v1/auth/login from %s",
@@ -239,12 +239,12 @@ def auth_login():
if not user or not user.check_password(password):
return jsonify({"error": "Invalid username or password"}), 401
scopes = "read:projects,read:tasks,read:time_entries,write:time_entries"
scopes = "admin:all" if user.is_admin else "read:*,write:*"
expiry_days = current_app.config.get("API_TOKEN_DEFAULT_EXPIRY_DAYS", 90)
api_token, plain_token = ApiToken.create_token(
user_id=user.id,
name=f"Mobile app - {user.username}",
description="Token issued via mobile/app login",
name=f"App login - {user.username}",
description="Token issued via desktop/mobile app login",
scopes=scopes,
expires_days=expiry_days if expiry_days else None,
)