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 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 13:32:08 -04:00
parent 82cbce9615
commit 1914e6f28e
2 changed files with 72 additions and 0 deletions
+20
View File
@@ -187,6 +187,26 @@ def authenticate(username: str, password: str) -> "User | None":
return 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 # Session management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+52
View File
@@ -64,3 +64,55 @@ def test_validate_state_expired(monkeypatch):
# Wind expiry into the past # Wind expiry into the past
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1000) monkeypatch.setitem(oauth._states, state, time_mod.time() - 1000)
assert oauth.validate_state(state) is False 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"