fix: block /auth/token for OAuth-only users without password_hash

Users without a password_hash (Google OAuth / magic-link accounts) could
obtain a JWT by simply posting their email to /auth/token. Add an else
clause that rejects such requests with 401, directing them to their
configured auth provider. Update and extend tests accordingly.
This commit is contained in:
ZdenekSrotyr 2026-04-09 16:29:47 +02:00
parent 55515266ea
commit 3205a8d300
3 changed files with 33 additions and 9 deletions

View file

@ -38,7 +38,7 @@ async def create_token(
request: TokenRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Issue a JWT token. For dev/demo: any registered user gets a token."""
"""Issue a JWT token. Requires password authentication."""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
if not user:
@ -54,6 +54,12 @@ async def create_token(
ph.verify(user["password_hash"], request.password)
except Exception:
raise HTTPException(status_code=401, detail="Invalid password")
else:
# No password set — must use their auth provider (Google OAuth, magic link)
raise HTTPException(
status_code=401,
detail="This account uses external authentication. Please log in via your configured provider.",
)
token = create_access_token(
user_id=user["id"],

View file

@ -64,10 +64,15 @@ class TestTokenEndpoint:
assert data["email"] == "pw@test.com"
def test_token_no_password_hash_user_gets_token(self, client):
"""User without password_hash (e.g. OAuth-only user) still gets a token without a password."""
"""User without password_hash (OAuth-only) must be rejected at /auth/token."""
resp = client.post("/auth/token", json={"email": "ml@test.com"})
assert resp.status_code == 200
assert "access_token" in resp.json()
assert resp.status_code == 401
def test_token_rejected_for_oauth_only_user(self, client):
"""OAuth-only user (no password_hash) must not receive a token via /auth/token."""
resp = client.post("/auth/token", json={"email": "ml@test.com"})
assert resp.status_code == 401
assert "external authentication" in resp.json()["detail"]
def test_token_unknown_user_rejected(self, client):
"""Unknown email must return 401."""

View file

@ -64,18 +64,31 @@ class TestBootstrap:
assert "already exist" in resp.json()["detail"]
def test_bootstrap_then_login(self, fresh_client):
"""After bootstrap, normal /auth/token login works."""
# Bootstrap
"""After bootstrap with password, /auth/token login works; without password it requires OAuth."""
# Bootstrap with a password
fresh_client.post("/auth/bootstrap", json={
"email": "admin@test.com",
"password": "adminpass123",
})
# Normal password login succeeds
resp = fresh_client.post("/auth/token", json={
"email": "admin@test.com",
"password": "adminpass123",
})
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
def test_bootstrap_no_password_token_rejected(self, fresh_client):
"""After passwordless bootstrap, /auth/token must reject the user (OAuth-only flow)."""
fresh_client.post("/auth/bootstrap", json={
"email": "admin@test.com",
})
# Normal login
resp = fresh_client.post("/auth/token", json={
"email": "admin@test.com",
})
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
assert resp.status_code == 401
def test_bootstrap_second_call_fails(self, fresh_client):
"""Second bootstrap call fails — endpoint self-deactivates."""