diff --git a/backend/auth.py b/backend/auth.py index d77f986..31caaec 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -43,6 +43,18 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) +def create_user_token(user: "models.User", expires_delta: Optional[timedelta] = None) -> str: + """Session token for a user. + + ``uid`` is the authoritative subject: the immutable row id. ``sub`` keeps + the username for readability and for tokens issued before ``uid`` existed, + but it is not what identifies the account — otherwise renaming a user would + silently invalidate every session on their other devices, and an identity + provider that changes a name would break the mapping. + """ + return create_access_token({"sub": user.username, "uid": user.id}, expires_delta) + + def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ) -> models.User: @@ -57,12 +69,19 @@ def get_current_user( # flow token); only a real access token may authenticate a request. if payload.get("typ") not in (None, "access"): raise exc + uid = payload.get("uid") username: str = payload.get("sub") - if not username: + if uid is None and not username: raise exc except JWTError: raise exc - user = db.query(models.User).filter(models.User.username == username).first() + + if uid is not None: + user = db.query(models.User).filter(models.User.id == uid).first() + else: + # Legacy token issued before uid existed. "Remember me" runs for 180 + # days, so these stay in circulation for a while. + user = db.query(models.User).filter(models.User.username == username).first() if not user: raise exc return user diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 3aa16bd..0f9efc8 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -9,7 +9,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session import models -from auth import create_access_token, get_current_user, get_password_hash, verify_password +from auth import create_user_token, get_current_user, get_password_hash, verify_password from database import get_db # When "Angemeldet bleiben" is ticked the token lives for half a year. @@ -61,7 +61,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)): db.add(models.UserSettings(user_id=user.id)) db.commit() db.refresh(user) - token = create_access_token({"sub": user.username}) + token = create_user_token(user) return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} @@ -83,7 +83,7 @@ def login( status_code=status.HTTP_401_UNAUTHORIZED, detail="2fa_required", ) - token = create_access_token({"sub": user.username}) + token = create_user_token(user) return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} @@ -110,7 +110,7 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)): detail="Ungültiger 2FA-Code", ) expires = REMEMBER_ME_EXPIRY if req.remember_me else None - token = create_access_token({"sub": user.username}, expires_delta=expires) + token = create_user_token(user, expires_delta=expires) return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} diff --git a/backend/routers/oidc_router.py b/backend/routers/oidc_router.py index 369dd3c..ce2d6e2 100644 --- a/backend/routers/oidc_router.py +++ b/backend/routers/oidc_router.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session import models import oidc_client import oidc_config -from auth import ALGORITHM, SECRET_KEY, create_access_token, get_current_user +from auth import ALGORITHM, SECRET_KEY, create_user_token, get_current_user from database import get_db from dav_util import public_base from oidc_client import OIDCError @@ -275,7 +275,7 @@ def oidc_callback(provider_key: str, request: Request, return _fail(request, e.slug) logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key) - token = create_access_token({"sub": user.username}) + token = create_user_token(user) resp = RedirectResponse(url="/?sso=1", status_code=302) resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc") @@ -300,13 +300,13 @@ def oidc_complete(request: Request, response: Response, db: Session = Depends(ge try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username = payload.get("sub") + uid = payload.get("uid") except JWTError: raise HTTPException(401, "SSO-Sitzung ungültig") - if not username: + if uid is None: raise HTTPException(401, "SSO-Sitzung ungültig") - user = db.query(models.User).filter(models.User.username == username).first() + user = db.query(models.User).filter(models.User.id == uid).first() if not user: raise HTTPException(401, "SSO-Sitzung ungültig") @@ -404,7 +404,7 @@ def oidc_exchange(req: ExchangeRequest, db: Session = Depends(get_db)): logger.info("OIDC exchange (%s) user id=%s via %s", action, user.id, provider.key) return { - "access_token": create_access_token({"sub": user.username}), + "access_token": create_user_token(user), "token_type": "bearer", "user": _user_dict(user), } diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index 48b25b9..36d0840 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import Session from sqlalchemy import func import models -from auth import create_access_token, get_current_user, get_password_hash, verify_password +from auth import create_user_token, get_current_user, get_password_hash, verify_password from database import DATA_DIR, get_db router = APIRouter() @@ -116,7 +116,9 @@ def update_profile( db.commit() # The JWT 'sub' is the login name — renaming it invalidates the old # token, so hand back a fresh one for the client to store. - result["access_token"] = create_access_token({"sub": new_login}) + # The session survives a rename on its own (uid), but the token + # still carries the old name for display — hand out a fresh one. + result["access_token"] = create_user_token(current_user) return result if data.directory_hidden is not None: current_user.directory_hidden = data.directory_hidden diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index 243e3b4..2f32c07 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -623,6 +623,79 @@ def test_second_login_reuses_the_same_account(client, authentik, monkeypatch): assert db.query(models.OIDCIdentity).count() == 1 +# ── identity is the id, never the name ─────────────────────────────────────── + +def test_rename_at_the_provider_keeps_the_same_account(client, authentik, monkeypatch): + """Authentik renames a user: same sub, new preferred_username and email. + That must land on the same Calendarr account, unchanged.""" + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + + first = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="stable-sub", email="old@example.com", + preferred_username="oldname", name="Old Name")) + assert first.status_code == 200 + uid = first.json()["user"]["id"] + original_username = first.json()["user"]["username"] + + second = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="stable-sub", email="brand-new@example.com", + preferred_username="totally-different", name="New Name")) + assert second.status_code == 200 + assert second.json()["user"]["id"] == uid + # The local login name is ours, not the provider's — it does not churn. + assert second.json()["user"]["username"] == original_username + + with db_session() as db: + assert db.query(models.OIDCIdentity).count() == 1 + assert db.query(models.User).count() == 2 # admin + this one + + +def test_local_rename_keeps_existing_sessions_alive(client, authentik): + """A username change must not silently sign the user out everywhere: the + token identifies the account by id.""" + admin_token = register_admin(client, "admin", "pw") + _, token = create_user(client, admin_token, "gina") + + renamed = client.put("/api/profile/", headers=auth(token), + json={"username": "gina-married"}) + assert renamed.status_code == 200, renamed.text + + # The *old* token — as still held by another device — keeps working. + me = client.get("/api/auth/me", headers=auth(token)) + assert me.status_code == 200 + assert me.json()["username"] == "gina-married" + + +def test_local_rename_keeps_the_sso_link(client, authentik, monkeypatch): + """Renaming locally must not detach a linked SSO identity either.""" + admin_token = register_admin(client, "admin", "pw") + uid, token = create_user(client, admin_token, "gina") + with db_session() as db: + db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik", + issuer=ISSUER, subject="ak-gina")) + db.commit() + + client.put("/api/profile/", headers=auth(token), json={"username": "gina-married"}) + + r = exchange(client, id_token=make_id_token(sub="ak-gina", aud=MOBILE_CLIENT)) + assert r.status_code == 200 + assert r.json()["user"]["id"] == uid + + +def test_legacy_token_without_uid_still_works(client, authentik): + """Tokens minted before the uid claim stay valid — "remember me" runs for + 180 days, so they are in circulation for a long time.""" + from auth import create_access_token + + register_admin(client, "admin", "pw") + legacy = create_access_token({"sub": "admin"}) + me = client.get("/api/auth/me", headers=auth(legacy)) + assert me.status_code == 200 + assert me.json()["username"] == "admin" + + # ── the password_hash trap ─────────────────────────────────────────────────── def test_sso_user_password_login_fails_without_500(client, authentik, monkeypatch):