From 9a59911156b78ed976a36bb6c2bab8e1859cff2a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Fri, 24 Apr 2026 12:57:38 +0200
Subject: [PATCH] =?UTF-8?q?feat(ha):=20OAuth=20Authorization-Code-Flow=20s?=
=?UTF-8?q?tatt=20kaputtem=20Password-Grant=20Home=20Assistant=20unterst?=
=?UTF-8?q?=C3=BCtzt=20keinen=20Password-Grant=20=E2=80=94=20deshalb=20kam?=
=?UTF-8?q?=20immer=20"Ung=C3=BCltige=20Anmeldedaten",=20egal=20was=20eing?=
=?UTF-8?q?egeben=20wurde.=20Jetzt=20wird=20der=20Nutzer=20nach=20demselbe?=
=?UTF-8?q?n=20Muster=20wie=20bei=20Google=20zur=20HA-Login-Seite=20weiter?=
=?UTF-8?q?geleitet,=20meldet=20sich=20dort=20an=20und=20kommt=20zur=C3=BC?=
=?UTF-8?q?ck=20zu=20Calendarr.=20=C3=84nderungen:=20-=20Neuer=20POST=20/a?=
=?UTF-8?q?pi/homeassistant/auth-url=20und=20GET=20/callback=20Endpoint=20?=
=?UTF-8?q?-=20Account=20speichert=20client=5Fid=20f=C3=BCr=20sp=C3=A4tere?=
=?UTF-8?q?=20Token-Refreshes=20-=20Modal:=20"Benutzername/Passwort"=20?=
=?UTF-8?q?=E2=86=92=20"Mit=20Home=20Assistant=20anmelden"=20-=20Frontend?=
=?UTF-8?q?=20behandelt=20=3Fha=5Fconnected=3D1=20/=20=3Fha=5Ferror=3D...?=
=?UTF-8?q?=20nach=20R=C3=BCckkehr=20-=20Version=20v1=20=E2=86=92=20v2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 5 +
backend/models.py | 1 +
backend/routers/homeassistant_router.py | 193 +++++++++++++++++-------
frontend/index.html | 45 +++---
frontend/js/calendar.js | 103 +++++++++----
frontend/js/version.js | 2 +-
6 files changed, 235 insertions(+), 114 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index ca24b29..bb1327a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -78,6 +78,11 @@ def _migrate():
conn.commit()
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
+ conn.commit()
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index 4772ef0..36fc462 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -192,6 +192,7 @@ class HomeAssistantAccount(Base):
auth_method = Column(String(20), default="token")
refresh_token = Column(Text, nullable=True)
token_expiry = Column(DateTime, nullable=True)
+ client_id = Column(String(500), nullable=True)
user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship(
diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py
index 77f4e05..e6ef38a 100644
--- a/backend/routers/homeassistant_router.py
+++ b/backend/routers/homeassistant_router.py
@@ -1,9 +1,13 @@
import logging
+import secrets
+import time
from datetime import datetime, timezone
from typing import Optional
+from urllib.parse import urlencode
import requests as http_requests
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -15,44 +19,27 @@ logger = logging.getLogger(__name__)
router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4"
-HA_CLIENT_ID = "http://localhost/"
+
+# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
+_pending_oauth: dict[str, dict] = {}
+
+
+def _cleanup_pending():
+ now = time.time()
+ for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
+ _pending_oauth.pop(k, None)
# ── Auth helpers ──────────────────────────────────────────
-def _ha_login(url: str, username: str, password: str) -> tuple:
- """Password grant → (access_token, refresh_token, expires_in)"""
- try:
- resp = http_requests.post(
- f"{url.rstrip('/')}/auth/token",
- data={
- "grant_type": "password",
- "username": username,
- "password": password,
- "client_id": HA_CLIENT_ID,
- },
- timeout=10,
- verify=False,
- )
- except http_requests.exceptions.ConnectionError:
- raise HTTPException(503, "Home Assistant nicht erreichbar")
- except http_requests.exceptions.Timeout:
- raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
- if resp.status_code in (400, 401):
- raise HTTPException(400, "Ungültige Anmeldedaten")
- resp.raise_for_status()
- data = resp.json()
- return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800)
-
-
-def _ha_refresh(url: str, refresh_token: str) -> tuple:
+def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
"""Refresh grant → (access_token, expires_in)"""
resp = http_requests.post(
f"{url.rstrip('/')}/auth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
- "client_id": HA_CLIENT_ID,
+ "client_id": client_id,
},
timeout=10,
verify=False,
@@ -64,14 +51,16 @@ def _ha_refresh(url: str, refresh_token: str) -> tuple:
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
"""Return a valid access token, refreshing if necessary."""
- if account.auth_method != "password":
+ if account.auth_method != "oauth":
return account.token # Long-Lived Token läuft nicht ab
now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.token
# Needs refresh
try:
- access_token, expires_in = _ha_refresh(account.url, account.refresh_token)
+ access_token, expires_in = _ha_refresh(
+ account.url, account.refresh_token, account.client_id or ""
+ )
except Exception as exc:
logger.error("HA token refresh failed for %s: %s", account.name, exc)
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
@@ -188,9 +177,14 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
class HAAccountCreate(BaseModel):
name: str
url: str
- token: Optional[str] = None
- username: Optional[str] = None
- password: Optional[str] = None
+ token: str
+
+
+class HAOAuthStart(BaseModel):
+ name: str
+ url: str
+ client_id: str
+ redirect_uri: str
class HACalendarUpdate(BaseModel):
@@ -221,31 +215,17 @@ def add_account(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- now = datetime.now(timezone.utc)
-
- if data.username and data.password:
- access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password)
- auth_method = "password"
- stored_refresh = refresh_tok
- token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
- elif data.token:
- access_token = data.token
- auth_method = "token"
- stored_refresh = None
- token_expiry = None
- else:
- raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich")
-
- remote_cals = _ha_get_calendars(data.url, access_token)
+ """Create a HA account from a Long-Lived Access Token."""
+ remote_cals = _ha_get_calendars(data.url, data.token)
account = models.HomeAssistantAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
- token=access_token,
- auth_method=auth_method,
- refresh_token=stored_refresh,
- token_expiry=token_expiry,
+ token=data.token,
+ auth_method="token",
+ refresh_token=None,
+ token_expiry=None,
)
db.add(account)
db.flush()
@@ -267,6 +247,111 @@ def add_account(
return _account_dict(account)
+@router.post("/auth-url")
+def oauth_start(
+ data: HAOAuthStart,
+ current_user: models.User = Depends(get_current_user),
+):
+ """Start the OAuth flow: store pending state, return HA authorization URL."""
+ _cleanup_pending()
+ state_token = secrets.token_urlsafe(32)
+ _pending_oauth[state_token] = {
+ "user_id": current_user.id,
+ "ha_url": data.url.rstrip('/'),
+ "name": data.name,
+ "client_id": data.client_id,
+ "redirect_uri": data.redirect_uri,
+ "expires": time.time() + 600,
+ }
+ params = {
+ "client_id": data.client_id,
+ "redirect_uri": data.redirect_uri,
+ "state": state_token,
+ "response_type": "code",
+ }
+ return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
+
+
+@router.get("/callback")
+def oauth_callback(
+ request: Request,
+ code: str = Query(""),
+ state: str = Query(""),
+ error: str = Query(""),
+ db: Session = Depends(get_db),
+):
+ """Callback from Home Assistant after user authorization."""
+ if error or not code:
+ return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
+
+ pending = _pending_oauth.pop(state, None)
+ if not pending or pending["expires"] < time.time():
+ return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
+
+ ha_url = pending["ha_url"]
+ client_id = pending["client_id"]
+
+ # Exchange code for tokens
+ try:
+ resp = http_requests.post(
+ f"{ha_url}/auth/token",
+ data={
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": client_id,
+ },
+ timeout=15,
+ verify=False,
+ )
+ except Exception as exc:
+ logger.error("HA token exchange connection error: %s", exc)
+ return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
+
+ if resp.status_code != 200:
+ logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
+ return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
+
+ tokens = resp.json()
+ access_token = tokens["access_token"]
+ refresh_token = tokens.get("refresh_token", "")
+ expires_in = tokens.get("expires_in", 1800)
+ now = datetime.now(timezone.utc)
+
+ try:
+ remote_cals = _ha_get_calendars(ha_url, access_token)
+ except HTTPException as exc:
+ logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
+ return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
+
+ account = models.HomeAssistantAccount(
+ user_id=pending["user_id"],
+ name=pending["name"],
+ url=ha_url,
+ token=access_token,
+ auth_method="oauth",
+ refresh_token=refresh_token,
+ token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
+ client_id=client_id,
+ )
+ db.add(account)
+ db.flush()
+
+ for cal in remote_cals:
+ entity_id = cal.get("entity_id", "")
+ if not entity_id:
+ continue
+ db.add(models.HomeAssistantCalendar(
+ account_id=account.id,
+ entity_id=entity_id,
+ name=cal.get("name") or entity_id,
+ color=None,
+ enabled=True,
+ ))
+
+ db.commit()
+ return RedirectResponse(url="/?ha_connected=1", status_code=302)
+
+
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
diff --git a/frontend/index.html b/frontend/index.html
index b327ee9..f202162 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v1
+ Calendarr v2
@@ -71,7 +71,7 @@
-
+
@@ -118,7 +118,7 @@
Profil
@@ -173,7 +173,7 @@
-
+
@@ -208,7 +208,7 @@
@@ -226,7 +226,7 @@
@@ -276,10 +276,10 @@
@@ -421,32 +421,25 @@
Anmeldemethode
-
@@ -689,7 +682,7 @@
@@ -765,7 +758,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 925526b..5faf66a 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -75,6 +75,32 @@ export async function initCalendar() {
bindHAAccountModal();
bindSettingsModal();
bindProfileModal();
+ handleHAOAuthReturn();
+}
+
+function handleHAOAuthReturn() {
+ const params = new URLSearchParams(window.location.search);
+ const errMap = {
+ no_code: 'Home Assistant hat keinen Autorisierungscode zurückgegeben',
+ state_expired: 'Die Anmeldung ist abgelaufen, bitte erneut versuchen',
+ ha_unreachable: 'Home Assistant nicht erreichbar',
+ token_exchange_failed: 'Token-Austausch mit Home Assistant fehlgeschlagen',
+ calendars_failed: 'Kalender konnten nicht geladen werden',
+ };
+ if (params.has('ha_connected')) {
+ showToast('Home Assistant verbunden');
+ window.history.replaceState({}, '', window.location.pathname);
+ fetchAndRender(true);
+ api.get('/homeassistant/accounts').then(accs => {
+ state.haAccounts = accs || [];
+ renderCalendarList();
+ renderAllAccounts?.();
+ }).catch(() => {});
+ } else if (params.has('ha_error')) {
+ const code = params.get('ha_error');
+ showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
+ window.history.replaceState({}, '', window.location.pathname);
+ }
}
// ── Event cache ───────────────────────────────────────────
@@ -1266,30 +1292,32 @@ function openHAAccountModal() {
document.getElementById('ha-account-name').value = '';
document.getElementById('ha-account-url').value = '';
document.getElementById('ha-account-token').value = '';
- document.getElementById('ha-account-username').value = '';
- document.getElementById('ha-account-userpass').value = '';
document.getElementById('ha-account-error').classList.add('hidden');
- // Reset to token method
- document.getElementById('ha-auth-token').checked = true;
- document.getElementById('ha-token-group').classList.remove('hidden');
- document.getElementById('ha-credentials-group').classList.add('hidden');
+ // Reset to OAuth method
+ document.getElementById('ha-auth-oauth').checked = true;
+ document.getElementById('ha-oauth-info').classList.remove('hidden');
+ document.getElementById('ha-token-group').classList.add('hidden');
+ document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden';
openModal('modal-ha-account');
}
function bindHAAccountModal() {
- // Toggle auth method fields
+ // Toggle auth method fields + save button label
document.querySelectorAll('[name="ha-auth-method"]').forEach(r => {
r.addEventListener('change', () => {
- const isPw = document.getElementById('ha-auth-password').checked;
- document.getElementById('ha-token-group').classList.toggle('hidden', isPw);
- document.getElementById('ha-credentials-group').classList.toggle('hidden', !isPw);
+ const isOAuth = document.getElementById('ha-auth-oauth').checked;
+ document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth);
+ document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth);
+ document.getElementById('ha-account-save').textContent = isOAuth
+ ? 'Mit Home Assistant anmelden'
+ : 'Verbinden';
});
});
document.getElementById('ha-account-save').onclick = async () => {
const name = document.getElementById('ha-account-name').value.trim();
const url = document.getElementById('ha-account-url').value.trim();
- const isPw = document.getElementById('ha-auth-password').checked;
+ const isOAuth = document.getElementById('ha-auth-oauth').checked;
const errEl = document.getElementById('ha-account-error');
if (!name || !url) {
@@ -1297,34 +1325,43 @@ function bindHAAccountModal() {
errEl.classList.remove('hidden');
return;
}
-
- const body = { name, url };
- if (isPw) {
- const username = document.getElementById('ha-account-username').value.trim();
- const password = document.getElementById('ha-account-userpass').value.trim();
- if (!username || !password) {
- errEl.textContent = 'Bitte Benutzername und Passwort ausfüllen';
- errEl.classList.remove('hidden');
- return;
- }
- body.username = username;
- body.password = password;
- } else {
- const token = document.getElementById('ha-account-token').value.trim();
- if (!token) {
- errEl.textContent = 'Bitte Access Token ausfüllen';
- errEl.classList.remove('hidden');
- return;
- }
- body.token = token;
- }
errEl.classList.add('hidden');
const saveBtn = document.getElementById('ha-account-save');
saveBtn.disabled = true;
+
+ if (isOAuth) {
+ saveBtn.textContent = 'Weiterleiten…';
+ try {
+ const base = window.location.origin;
+ const resp = await api.post('/homeassistant/auth-url', {
+ name,
+ url,
+ client_id: base + '/',
+ redirect_uri: base + '/api/homeassistant/callback',
+ });
+ if (!resp) return;
+ window.location.href = resp.url;
+ } catch (e) {
+ errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung';
+ errEl.classList.remove('hidden');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Mit Home Assistant anmelden';
+ }
+ return;
+ }
+
+ // Long-Lived Token flow
+ const token = document.getElementById('ha-account-token').value.trim();
+ if (!token) {
+ errEl.textContent = 'Bitte Access Token ausfüllen';
+ errEl.classList.remove('hidden');
+ saveBtn.disabled = false;
+ return;
+ }
saveBtn.textContent = 'Verbinde…';
try {
- const account = await api.post('/homeassistant/accounts', body);
+ const account = await api.post('/homeassistant/accounts', { name, url, token });
if (!account) return;
state.haAccounts.push(account);
renderCalendarList();
diff --git a/frontend/js/version.js b/frontend/js/version.js
index c3d71fe..9d3baa6 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v1';
+export const APP_VERSION = 'v2';