Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions startupintel/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
LoginRequest,
RefreshTokenRequest,
RegisterRequest,
RegisterResponse,
TokenResponse,
UserResponse,
UserRole,
VerifyEmailRequest,
)
from startupintel.db.models import Organization, RefreshToken, User
from startupintel.utils.auth import (
create_access_token,
create_email_verification_token,
create_refresh_token,
decode_token,
get_password_hash,
Expand All @@ -31,12 +34,12 @@
router = APIRouter(prefix="/auth", tags=["authentication"])


@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequest,
db: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Register a new user (auto-creates an organisation)."""
) -> RegisterResponse:
"""Register a new user (inactive until email verification)."""
existing = await db.execute(select(User).where(User.email == request.email))
if existing.scalar_one_or_none():
raise HTTPException(
Expand All @@ -59,11 +62,41 @@ async def register(
last_name=request.last_name,
role=UserRole.ADMIN.value,
organization_id=org.id,
is_active=False,
email_verified=False,
)
db.add(user)
await db.commit()
await db.refresh(user)

token = create_email_verification_token(user.id)
payload = UserResponse.model_validate(user).model_dump()
return RegisterResponse(**payload, verification_token=token)


@router.post("/verify-email", response_model=UserResponse)
async def verify_email(
request: VerifyEmailRequest,
db: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Activate an account after the user confirms their email token."""
payload = decode_token(request.token)
if not payload or payload.get("type") != "email_verify":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification token",
)

from uuid import UUID

user = await db.get(User, UUID(payload["sub"]))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")

user.email_verified = True
user.is_active = True
await db.commit()
await db.refresh(user)
return UserResponse.model_validate(user)


Expand All @@ -82,10 +115,10 @@ async def login(
detail="Invalid email or password",
)

if not user.is_active:
if not user.is_active or not user.email_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
detail="Email not verified — check your inbox or call /auth/verify-email",
)

access_token, _ = create_access_token(
Expand Down
10 changes: 10 additions & 0 deletions startupintel/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ class TokenResponse(BaseModel):
user: UserResponse


class RegisterResponse(UserResponse):
"""Registration payload including a verification token (emailed in production)."""

verification_token: str


class VerifyEmailRequest(BaseModel):
token: str


class BotRunResponse(BaseModel):
startup_id: UUID
bot_name: str
Expand Down
27 changes: 26 additions & 1 deletion startupintel/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -920,9 +920,31 @@
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}

.login-modal { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: grid; place-items: center; z-index: 1000; }
.login-modal[hidden] { display: none !important; }
.login-card { background: var(--bg-elevated); border: 1px solid var(--border-default); border-radius: 12px; padding: 1.5rem; width: min(360px, 92vw); display: grid; gap: .75rem; }
.login-card label { display: grid; gap: .35rem; font-size: .85rem; color: var(--text-secondary); }
.login-card input { background: var(--bg-secondary); border: 1px solid var(--border-default); color: var(--text-primary); border-radius: 8px; padding: .6rem .75rem; }
.login-card button { background: var(--accent-gradient); color: white; border: 0; border-radius: 8px; padding: .7rem 1rem; font-weight: 600; cursor: pointer; }
.login-error { color: var(--danger); font-size: .85rem; }
.login-hint { color: var(--text-tertiary); font-size: .8rem; }

</style>
</head>
<body>

<div id="login-modal" class="login-modal" hidden>
<div class="login-card">
<h2>Sign in</h2>
<p class="login-hint">JWT is sent as Bearer on API calls.</p>
<label>Email <input id="login-email" type="email" autocomplete="username" /></label>
<label>Password <input id="login-password" type="password" autocomplete="current-password" /></label>
<p id="login-error" class="login-error" hidden></p>
<button type="button" id="login-submit">Sign in</button>
</div>
</div>

<div class="app-container">
<!-- Header -->
<header class="header">
Expand Down Expand Up @@ -1159,13 +1181,16 @@ <h1 class="welcome-title">StartupIntel</h1>
this.messages = [];
this.isStreaming = false;
this.apiBaseUrl = '/api';
this.authToken = localStorage.getItem('startupintel_access_token');
this.authUser = null;

this.init();
}

init() {
this.bindElements();
this.bindEvents();
this.ensureAuthenticated();
this.loadConversations();
this.setupRoleBasedUI();
}
Expand Down Expand Up @@ -1265,7 +1290,7 @@ <h1 class="welcome-title">StartupIntel</h1>
try {
const response = await fetch(`${this.apiBaseUrl}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
message: message,
conversation_id: this.conversationId,
Expand Down
13 changes: 13 additions & 0 deletions startupintel/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,16 @@ def create_password_reset_token(user_id: UUID) -> str:
}

return jwt.encode(to_encode, settings.api_secret_key, algorithm="HS256")

def create_email_verification_token(user_id: UUID) -> str:
"""Create a short-lived JWT used to verify a newly registered email."""
settings = get_settings()
expire = datetime.now(UTC) + timedelta(hours=48)
payload = {
"sub": str(user_id),
"type": "email_verify",
"exp": expire,
"iat": datetime.now(UTC),
}
return jwt.encode(payload, settings.api_secret_key, algorithm="HS256")

120 changes: 43 additions & 77 deletions tests/test_api/test_auth_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Integration tests for the auth API (register → login → refresh → me → logout)."""
"""Integration tests for the auth API (register → verify → login → refresh → me → logout)."""

from __future__ import annotations

Expand All @@ -24,6 +24,19 @@ async def _register(
return resp.json()


async def _verify(client: AsyncClient, token: str) -> dict:
resp = await client.post("/auth/verify-email", json={"token": token})
assert resp.status_code == 200, resp.text
return resp.json()


async def _register_and_verify(
client: AsyncClient, *, email: str = "alice@example.com", password: str = "str0ngP@ss"
) -> dict:
user = await _register(client, email=email, password=password)
return await _verify(client, user["verification_token"])


async def _login(
client: AsyncClient, *, email: str = "alice@example.com", password: str = "str0ngP@ss"
) -> dict:
Expand All @@ -32,137 +45,90 @@ async def _login(
return resp.json()


async def test_register_creates_user_and_org(client: AsyncClient):
async def test_register_creates_inactive_unverified_user(client: AsyncClient):
user = await _register(client)
assert user["email"] == "alice@example.com"
assert user["role"] == "admin"
assert user["organization_id"] is not None
assert user["is_active"] is False
assert user["email_verified"] is False
assert user["verification_token"]


async def test_register_duplicate_email_rejects(client: AsyncClient):
await _register(client)
resp = await client.post(
"/auth/register",
json={
"email": "alice@example.com",
"password": "str0ngP@ss",
},
json={"email": "alice@example.com", "password": "str0ngP@ss"},
)
assert resp.status_code == 400
assert "already registered" in resp.json()["detail"].lower()


async def test_login_returns_tokens(client: AsyncClient):
async def test_login_before_verify_forbidden(client: AsyncClient):
await _register(client)
resp = await client.post(
"/auth/login",
json={"email": "alice@example.com", "password": "str0ngP@ss"},
)
assert resp.status_code == 403


async def test_verify_then_login_returns_tokens(client: AsyncClient):
await _register_and_verify(client)
tokens = await _login(client)
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
assert tokens["user"]["email"] == "alice@example.com"
assert tokens["user"]["email_verified"] is True
assert tokens["user"]["is_active"] is True


async def test_login_wrong_password_rejects(client: AsyncClient):
await _register(client)
await _register_and_verify(client)
resp = await client.post(
"/auth/login",
json={
"email": "alice@example.com",
"password": "wrong",
},
json={"email": "alice@example.com", "password": "wrong"},
)
assert resp.status_code == 401


async def test_refresh_rotates_token(client: AsyncClient):
await _register(client)
await _register_and_verify(client)
tokens = await _login(client)

resp = await client.post(
"/auth/refresh",
json={
"refresh_token": tokens["refresh_token"],
},
json={"refresh_token": tokens["refresh_token"]},
)
assert resp.status_code == 200
assert resp.status_code == 200, resp.text
new_tokens = resp.json()
assert new_tokens["access_token"] != tokens["access_token"]
assert new_tokens["refresh_token"] != tokens["refresh_token"]


async def test_refresh_revoked_token_rejects(client: AsyncClient):
await _register(client)
tokens = await _login(client)

# first refresh succeeds, revoking the old token
await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]})

# second refresh with same (now-revoked) token fails
resp = await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]})
assert resp.status_code == 401


async def test_refresh_expired_token_rejects(client: AsyncClient, db_session):
"""Expired refresh tokens must not rotate into a new pair (#41)."""
from datetime import UTC, datetime, timedelta
import hashlib

from sqlalchemy import select

from startupintel.db.models import RefreshToken

await _register(client)
async def test_me_requires_bearer(client: AsyncClient):
await _register_and_verify(client)
tokens = await _login(client)
token_hash = hashlib.sha256(tokens["refresh_token"].encode()).hexdigest()

result = await db_session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
row = result.scalar_one()
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
await db_session.commit()

resp = await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]})
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()


async def test_me_returns_profile(client: AsyncClient):
await _register(client)
tokens = await _login(client)

resp = await client.get(
"/auth/me",
headers={
"Authorization": f"Bearer {tokens['access_token']}",
},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "alice@example.com"


async def test_me_rejects_without_token(client: AsyncClient):
resp = await client.get("/auth/me")
assert resp.status_code == 401


async def test_logout_revokes_token(client: AsyncClient):
await _register(client)
async def test_logout_revokes_refresh(client: AsyncClient):
await _register_and_verify(client)
tokens = await _login(client)

resp = await client.post(
"/auth/logout",
json={
"refresh_token": tokens["refresh_token"],
},
json={"refresh_token": tokens["refresh_token"]},
)
assert resp.status_code == 200
assert "logged out" in resp.json()["message"].lower()

# revoked token should fail on refresh
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": tokens["refresh_token"],
},
json={"refresh_token": tokens["refresh_token"]},
)
assert resp.status_code == 401
Loading