Skip to content

Commit d0a8d88

Browse files
rafizamankhanchriscarrollsmith
authored andcommitted
add invalidate old invites
1 parent d212af3 commit d0a8d88

9 files changed

Lines changed: 278 additions & 53 deletions

File tree

exceptions/http_exceptions.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,31 @@ def __init__(self):
186186

187187

188188
class InvalidInvitationTokenError(HTTPException):
189-
"""Raised when an invitation token is invalid, expired, or not found."""
189+
"""Raised when an invitation token is missing, superseded, or already used."""
190190

191191
def __init__(self):
192-
super().__init__(status_code=404, detail="Invitation not found or expired")
192+
super().__init__(
193+
status_code=404,
194+
detail=(
195+
"This invitation link is no longer valid. "
196+
"If you were invited again recently, use the link from the "
197+
"most recent invitation email."
198+
),
199+
)
200+
201+
202+
class ExpiredInvitationTokenError(HTTPException):
203+
"""Raised when an invitation token exists but has passed its expiry date."""
204+
205+
def __init__(self):
206+
super().__init__(
207+
status_code=404,
208+
detail=(
209+
"This invitation link has expired. Ask your organization "
210+
"administrator to send a new invitation, then use the link in "
211+
"the latest email."
212+
),
213+
)
193214

194215

195216
class InvitationEmailMismatchError(HTTPException):

routers/core/account.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
DataIntegrityError,
1414
Account,
1515
AccountEmail,
16-
Invitation,
1716
)
1817
from utils.core.dependencies import get_session
1918
from utils.core.models import RefreshToken
@@ -54,14 +53,17 @@
5453
EmailNotVerifiedError,
5554
MaxEmailsReachedError,
5655
PasswordValidationError,
57-
InvalidInvitationTokenError,
5856
InvitationEmailMismatchError,
5957
InvitationProcessingError,
6058
)
6159
from routers.core.dashboard import router as dashboard_router
6260
from routers.core.user import router as user_router
6361
from routers.core.organization import router as org_router
64-
from utils.core.invitations import process_invitation
62+
from utils.core.invitations import (
63+
process_invitation,
64+
require_active_invitation_by_token,
65+
get_invitation_token_warning,
66+
)
6567
from utils.core.rate_limit import (
6668
check_login_ip_rate_limit,
6769
check_login_email_rate_limit,
@@ -149,14 +151,24 @@ async def read_login(
149151
request: Request,
150152
_: None = Depends(require_unauthenticated_client),
151153
invitation_token: Optional[str] = Query(None),
154+
session: Session = Depends(get_session),
152155
):
153156
"""
154157
Render login page or redirect to dashboard if already logged in.
155158
"""
159+
invitation_token_warning = (
160+
get_invitation_token_warning(session, invitation_token)
161+
if invitation_token
162+
else None
163+
)
156164
return templates.TemplateResponse(
157165
request,
158166
"account/login.html",
159-
{"user": None, "invitation_token": invitation_token},
167+
{
168+
"user": None,
169+
"invitation_token": invitation_token,
170+
"invitation_token_warning": invitation_token_warning,
171+
},
160172
)
161173

162174

@@ -166,10 +178,16 @@ async def read_register(
166178
_: None = Depends(require_unauthenticated_client),
167179
email: Optional[EmailStr] = Query(None),
168180
invitation_token: Optional[str] = Query(None),
181+
session: Session = Depends(get_session),
169182
):
170183
"""
171184
Render registration page or redirect to dashboard if already logged in.
172185
"""
186+
invitation_token_warning = (
187+
get_invitation_token_warning(session, invitation_token)
188+
if invitation_token
189+
else None
190+
)
173191
return templates.TemplateResponse(
174192
request,
175193
"account/register.html",
@@ -178,6 +196,7 @@ async def read_register(
178196
"password_pattern": HTML_PASSWORD_PATTERN,
179197
"email": email,
180198
"invitation_token": invitation_token,
199+
"invitation_token_warning": invitation_token_warning,
181200
},
182201
)
183202

@@ -317,16 +336,7 @@ async def register(
317336
logger.info(
318337
f"Registration attempt with invitation token: {invitation_token} for email {email}"
319338
)
320-
# Fetch the invitation
321-
statement = select(Invitation).where(Invitation.token == invitation_token)
322-
invitation = session.exec(statement).first()
323-
324-
if not invitation or not invitation.is_active():
325-
logger.warning(
326-
f"Invalid or inactive invitation token provided during registration: {invitation_token}"
327-
)
328-
# Consider raising a more generic error to avoid exposing token validity
329-
raise InvalidInvitationTokenError()
339+
invitation = require_active_invitation_by_token(session, invitation_token)
330340

331341
# Verify email matches
332342
if email != invitation.invitee_email:
@@ -434,15 +444,7 @@ async def login(
434444
logger.info(
435445
f"Login attempt with invitation token: {invitation_token} for account {account.email}"
436446
)
437-
# Fetch the invitation
438-
statement = select(Invitation).where(Invitation.token == invitation_token)
439-
invitation = session.exec(statement).first()
440-
441-
if not invitation or not invitation.is_active():
442-
logger.warning(
443-
f"Invalid or inactive invitation token provided during login: {invitation_token}"
444-
)
445-
raise InvalidInvitationTokenError()
447+
invitation = require_active_invitation_by_token(session, invitation_token)
446448

447449
# Verify email matches (check primary and any verified secondary emails)
448450
account_emails = session.exec(

routers/core/invitation.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
from utils.core.models import User, Role, Account, Invitation, Organization
1717
from utils.core.enums import ValidPermissions
1818
from utils.app.enums import AppPermissions
19-
from utils.core.invitations import send_invitation_email, process_invitation
19+
from utils.core.invitations import (
20+
send_invitation_email,
21+
process_invitation,
22+
require_active_invitation_by_token,
23+
)
2024
from exceptions.http_exceptions import (
2125
UserIsAlreadyMemberError,
22-
ActiveInvitationExistsError,
2326
InvalidRoleForOrganizationError,
2427
OrganizationNotFoundError,
2528
InvitationEmailSendError,
26-
InvalidInvitationTokenError,
2729
InvitationNotFoundError,
2830
InsufficientPermissionsError,
2931
RoleNotFoundError,
@@ -50,11 +52,7 @@ def get_valid_invitation(
5052
token: str = Query(...), session: Session = Depends(get_session)
5153
) -> Invitation:
5254
"""Dependency to retrieve a valid, active invitation based on the token."""
53-
statement = select(Invitation).where(Invitation.token == token)
54-
invitation = session.exec(statement).first()
55-
if not invitation or not invitation.is_active():
56-
raise InvalidInvitationTokenError()
57-
return invitation
55+
return require_active_invitation_by_token(session, token)
5856

5957

6058
@router.post("/", name="create_invitation")
@@ -108,12 +106,9 @@ async def create_invitation(
108106
):
109107
raise UserIsAlreadyMemberError()
110108

111-
# Check for active invitations with the same email
112-
active_invitations = Invitation.get_active_for_org(session, organization_id)
113-
if any(
114-
invitation.invitee_email == invitee_email for invitation in active_invitations
115-
):
116-
raise ActiveInvitationExistsError()
109+
# Replace any pending invite for this email (active or expired) before creating a new one.
110+
Invitation.invalidate_pending_for_email(session, organization_id, invitee_email)
111+
session.flush()
117112

118113
# Create the invitation
119114
token = str(uuid4())
@@ -147,7 +142,7 @@ async def create_invitation(
147142
logger.error(
148143
f"Invitation email failed for {invitee_email} in org {organization_id}: {e}"
149144
)
150-
session.rollback() # Rollback the invitation creation
145+
session.rollback() # Rollback invalidation and invitation creation
151146
raise InvitationEmailSendError() # Raise HTTP 500
152147
except Exception as e:
153148
# Catch any other unexpected errors during flush/refresh/email/commit

templates/account/login.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
{% block auth_content %}
88
<div class="login-form">
9+
{% if invitation_token_warning == 'expired' %}
10+
<div class="alert alert-warning" role="alert">
11+
This invitation link has expired. Ask your organization administrator to send a new invitation, then use the link in the latest email.
12+
</div>
13+
{% elif invitation_token_warning == 'invalid' %}
14+
<div class="alert alert-warning" role="alert">
15+
This invitation link is no longer valid. If you were invited again recently, use the link from the most recent invitation email.
16+
</div>
17+
{% endif %}
918
<form method="POST" action="{{ url_for('login') }}"
1019
hx-post="{{ url_for('login') }}" hx-swap="none"
1120
class="needs-validation" novalidate>

templates/account/register.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
{% block auth_content %}
88
<div class="register-form">
9+
{% if invitation_token_warning == 'expired' %}
10+
<div class="alert alert-warning" role="alert">
11+
This invitation link has expired. Ask your organization administrator to send a new invitation, then use the link in the latest email.
12+
</div>
13+
{% elif invitation_token_warning == 'invalid' %}
14+
<div class="alert alert-warning" role="alert">
15+
This invitation link is no longer valid. If you were invited again recently, use the link from the most recent invitation email.
16+
</div>
17+
{% endif %}
918
<form method="POST" action="{{ url_for('register') }}"
1019
hx-post="{{ url_for('register') }}" hx-swap="none"
1120
class="needs-validation" novalidate>

tests/routers/core/test_invitation.py

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
from datetime import datetime, timedelta, UTC
33
from unittest.mock import MagicMock
4-
from sqlmodel import Session, select
4+
from sqlmodel import Session, select, col
55
from tests.conftest import SetupError
66
from utils.core.models import Role, Permission, User, Invitation, Organization, Account
77
from utils.core.enums import ValidPermissions
@@ -498,24 +498,124 @@ def test_create_invitation_for_existing_member(
498498
) # Conflict - UserIsAlreadyMemberError
499499

500500

501-
def test_create_invitation_duplicate_active(
502-
auth_client, inviter_user: User, existing_invitation: Invitation
501+
def test_create_invitation_resend_replaces_active_invitation(
502+
auth_client,
503+
inviter_user: User,
504+
existing_invitation: Invitation,
505+
session: Session,
506+
mock_resend_send,
503507
):
504-
"""Test that creating a duplicate active invitation fails."""
508+
"""Re-inviting the same email replaces the pending invite and invalidates the old token."""
505509
assert existing_invitation.organization_id is not None
506510
assert existing_invitation.role_id is not None
511+
old_token = existing_invitation.token
512+
old_id = existing_invitation.id
513+
invitee_email = existing_invitation.invitee_email
514+
organization_id = existing_invitation.organization_id
515+
507516
response = auth_client.post(
508517
app.url_path_for("create_invitation"),
509518
data={
510-
"invitee_email": existing_invitation.invitee_email, # Same email
519+
"invitee_email": invitee_email,
520+
"role_id": str(existing_invitation.role_id),
521+
"organization_id": str(organization_id),
522+
},
523+
follow_redirects=False,
524+
)
525+
526+
assert response.status_code == 303, response.text
527+
528+
session.expire_all()
529+
assert session.get(Invitation, old_id) is None
530+
531+
new_invitation = session.exec(
532+
select(Invitation).where(
533+
Invitation.invitee_email == invitee_email,
534+
Invitation.organization_id == organization_id,
535+
col(Invitation.used).is_(False),
536+
)
537+
).first()
538+
assert new_invitation is not None
539+
assert new_invitation.token != old_token
540+
541+
accept_response = auth_client.get(
542+
app.url_path_for("accept_invitation"),
543+
params={"token": old_token},
544+
follow_redirects=False,
545+
)
546+
assert accept_response.status_code == 404
547+
assert "no longer valid" in accept_response.text.lower()
548+
549+
550+
def test_create_invitation_resend_after_expired_pending_invite(
551+
auth_client,
552+
inviter_user: User,
553+
expired_invitation: Invitation,
554+
session: Session,
555+
mock_resend_send,
556+
):
557+
"""Re-inviting after expiry succeeds even though the old row remains used=False in DB."""
558+
assert expired_invitation.organization_id is not None
559+
assert expired_invitation.role_id is not None
560+
expired_id = expired_invitation.id
561+
invitee_email = expired_invitation.invitee_email
562+
organization_id = expired_invitation.organization_id
563+
old_token = expired_invitation.token
564+
565+
response = auth_client.post(
566+
app.url_path_for("create_invitation"),
567+
data={
568+
"invitee_email": invitee_email,
569+
"role_id": str(expired_invitation.role_id),
570+
"organization_id": str(organization_id),
571+
},
572+
follow_redirects=False,
573+
)
574+
575+
assert response.status_code == 303, response.text
576+
session.expire_all()
577+
assert session.get(Invitation, expired_id) is None
578+
579+
replacement = session.exec(
580+
select(Invitation).where(
581+
Invitation.invitee_email == invitee_email,
582+
Invitation.organization_id == organization_id,
583+
col(Invitation.used).is_(False),
584+
)
585+
).first()
586+
assert replacement is not None
587+
assert replacement.token != old_token
588+
589+
590+
def test_create_invitation_resend_email_failure_restores_old_invite(
591+
auth_client,
592+
inviter_user: User,
593+
existing_invitation: Invitation,
594+
session: Session,
595+
mock_resend_send,
596+
):
597+
"""Failed resend rolls back both delete and new invite creation."""
598+
assert existing_invitation.id is not None
599+
old_token = existing_invitation.token
600+
mock_resend_send.side_effect = Exception("Simulated email send failure")
601+
602+
response = auth_client.post(
603+
app.url_path_for("create_invitation"),
604+
data={
605+
"invitee_email": existing_invitation.invitee_email,
511606
"role_id": str(existing_invitation.role_id),
512607
"organization_id": str(existing_invitation.organization_id),
513608
},
609+
follow_redirects=False,
514610
)
515611

516-
assert response.status_code == 409, (
517-
f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}"
518-
) # Conflict - ActiveInvitationExistsError
612+
assert response.status_code == 500
613+
session.expire_all()
614+
restored = session.exec(
615+
select(Invitation).where(Invitation.token == old_token)
616+
).first()
617+
assert restored is not None
618+
assert restored.id == existing_invitation.id
519619

520620

521621
def test_create_invitation_role_not_found(

0 commit comments

Comments
 (0)