From 1914e6f28ed12a98a2ec65fdbf7e78381da25a8e Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Fri, 8 May 2026 13:32:08 -0400 Subject: [PATCH] feat: add provision_oauth_user() to users module Creates or updates a user from an OAuth2 provider: new users are inserted with an empty password_hash (OAuth-only login); existing users have their display name and avatar refreshed while all other attributes (admin flag, password_hash, notification_channels) are preserved. Co-Authored-By: Claude Sonnet 4.6 --- hbd/server/users.py | 20 +++++++++++++++++ tests/test_oauth.py | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/hbd/server/users.py b/hbd/server/users.py index b515e57..aef730c 100644 --- a/hbd/server/users.py +++ b/hbd/server/users.py @@ -187,6 +187,26 @@ def authenticate(username: str, password: str) -> "User | None": return None +def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User": + """Create or update a user sourced from an OAuth2 provider. + + New users are inserted with no password_hash — they can only authenticate + via OAuth. Existing users (e.g. defined in config with a password) have + their display name and avatar refreshed; all other attributes are preserved. + """ + user = users.get(username) + if user is None: + user = User(username=username, full_name=full_name, avatar=avatar) + users[username] = user + logger.info("Provisioned OAuth user %r", username) + else: + if full_name: + user.full_name = full_name + if avatar: + user.avatar = avatar + return user + + # --------------------------------------------------------------------------- # Session management # --------------------------------------------------------------------------- diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 92787e2..8388ba2 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -64,3 +64,55 @@ def test_validate_state_expired(monkeypatch): # Wind expiry into the past monkeypatch.setitem(oauth._states, state, time_mod.time() - 1000) assert oauth.validate_state(state) is False + + +from hbd.server import users as users_mod +from hbd.server.users import User + + +def _reset_users(entries=None): + users_mod.users = entries or {} + + +def test_provision_oauth_user_new(): + _reset_users() + user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png") + assert user.username == "gituser" + assert user.full_name == "Git User" + assert user.avatar == "https://example.com/avatar.png" + assert user.admin is False + assert user.password_hash == "" + assert "gituser" in users_mod.users + + +def test_provision_oauth_user_no_password_login(): + _reset_users() + user = users_mod.provision_oauth_user("gituser", "Git User", "") + assert user.check_password("anything") is False + + +def test_provision_oauth_user_existing_updates_profile(): + existing = User( + username="alice", + full_name="Old Name", + avatar="old.png", + password_hash="pbkdf2:sha256:1:salt:abc", + admin=True, + notification_channels=["chan1"], + ) + _reset_users({"alice": existing}) + user = users_mod.provision_oauth_user("alice", "New Name", "new.png") + assert user.full_name == "New Name" + assert user.avatar == "new.png" + # Preserved + assert user.admin is True + assert user.password_hash == "pbkdf2:sha256:1:salt:abc" + assert user.notification_channels == ["chan1"] + + +def test_provision_oauth_user_does_not_overwrite_with_empty(): + existing = User(username="bob", full_name="Bob", avatar="bob.png") + _reset_users({"bob": existing}) + user = users_mod.provision_oauth_user("bob", "", "") + assert user.full_name == "Bob" + assert user.avatar == "bob.png"