feat: add authentication routes with session login, WebAuthn, and credential management
Implement Phase 4 auth routes: password login/logout, WebAuthn registration and authentication, magic link registration, and credential management pages with HTMX. Includes session middleware, Jinja2 templates, vendored HTMX, and last-credential guardrails. 120 tests passing.
This commit is contained in:
parent
f7ed2cf54d
commit
e15dcc4745
23 changed files with 1440 additions and 2 deletions
69
tests/test_auth_routes/test_last_credential_guard.py
Normal file
69
tests/test_auth_routes/test_last_credential_guard.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from httpx import AsyncClient
|
||||
|
||||
from fastapi_oidc_op.authn.password import PasswordService
|
||||
from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential
|
||||
|
||||
|
||||
async def _create_user_and_login(client: AsyncClient) -> str:
|
||||
"""Create user with password credential, log in, return userid."""
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
user_repo = app.state.user_repo
|
||||
cred_repo = app.state.credential_repo
|
||||
|
||||
user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC))
|
||||
await user_repo.create(user)
|
||||
|
||||
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass")))
|
||||
|
||||
await client.post(
|
||||
"/login/password",
|
||||
data={"username": "alice", "password": "testpass"},
|
||||
headers={"HX-Request": "true"},
|
||||
)
|
||||
return user.userid
|
||||
|
||||
|
||||
async def test_cannot_delete_last_password_credential(client: AsyncClient) -> None:
|
||||
"""User has only a password — cannot delete it."""
|
||||
await _create_user_and_login(client)
|
||||
|
||||
res = await client.delete(
|
||||
"/manage/credentials/password",
|
||||
headers={"HX-Request": "true"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert 'role="alert"' in res.text
|
||||
assert "last credential" in res.text.lower() or "Cannot remove" in res.text
|
||||
|
||||
# Password should still exist
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
cred = await app.state.credential_repo.get_password_by_user("lusab-bansen")
|
||||
assert cred is not None
|
||||
|
||||
|
||||
async def test_cannot_delete_last_webauthn_credential(client: AsyncClient) -> None:
|
||||
"""User has only one webauthn credential (password was removed) — cannot delete it."""
|
||||
userid = await _create_user_and_login(client)
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
cred_repo = app.state.credential_repo
|
||||
|
||||
# Add webauthn, then delete password (so webauthn is the only credential)
|
||||
await cred_repo.create_webauthn(WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1"))
|
||||
await cred_repo.delete_password(userid)
|
||||
|
||||
cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=")
|
||||
res = await client.delete(
|
||||
f"/manage/credentials/webauthn/{cred_id_b64}",
|
||||
headers={"HX-Request": "true"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert 'role="alert"' in res.text
|
||||
|
||||
# Credential should still exist
|
||||
creds = await cred_repo.get_webauthn_by_user(userid)
|
||||
assert len(creds) == 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue