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:
parent
55515266ea
commit
3205a8d300
3 changed files with 33 additions and 9 deletions
|
|
@ -38,7 +38,7 @@ async def create_token(
|
||||||
request: TokenRequest,
|
request: TokenRequest,
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
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)
|
repo = UserRepository(conn)
|
||||||
user = repo.get_by_email(request.email)
|
user = repo.get_by_email(request.email)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -54,6 +54,12 @@ async def create_token(
|
||||||
ph.verify(user["password_hash"], request.password)
|
ph.verify(user["password_hash"], request.password)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
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(
|
token = create_access_token(
|
||||||
user_id=user["id"],
|
user_id=user["id"],
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,15 @@ class TestTokenEndpoint:
|
||||||
assert data["email"] == "pw@test.com"
|
assert data["email"] == "pw@test.com"
|
||||||
|
|
||||||
def test_token_no_password_hash_user_gets_token(self, client):
|
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"})
|
resp = client.post("/auth/token", json={"email": "ml@test.com"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 401
|
||||||
assert "access_token" in resp.json()
|
|
||||||
|
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):
|
def test_token_unknown_user_rejected(self, client):
|
||||||
"""Unknown email must return 401."""
|
"""Unknown email must return 401."""
|
||||||
|
|
|
||||||
|
|
@ -64,18 +64,31 @@ class TestBootstrap:
|
||||||
assert "already exist" in resp.json()["detail"]
|
assert "already exist" in resp.json()["detail"]
|
||||||
|
|
||||||
def test_bootstrap_then_login(self, fresh_client):
|
def test_bootstrap_then_login(self, fresh_client):
|
||||||
"""After bootstrap, normal /auth/token login works."""
|
"""After bootstrap with password, /auth/token login works; without password it requires OAuth."""
|
||||||
# Bootstrap
|
# 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={
|
fresh_client.post("/auth/bootstrap", json={
|
||||||
"email": "admin@test.com",
|
"email": "admin@test.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Normal login
|
|
||||||
resp = fresh_client.post("/auth/token", json={
|
resp = fresh_client.post("/auth/token", json={
|
||||||
"email": "admin@test.com",
|
"email": "admin@test.com",
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 401
|
||||||
assert resp.json()["role"] == "admin"
|
|
||||||
|
|
||||||
def test_bootstrap_second_call_fails(self, fresh_client):
|
def test_bootstrap_second_call_fails(self, fresh_client):
|
||||||
"""Second bootstrap call fails — endpoint self-deactivates."""
|
"""Second bootstrap call fails — endpoint self-deactivates."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue