-
Notifications
You must be signed in to change notification settings - Fork 10
Feat: User Invitation flow #739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
74d5cfc
bd48eb8
10fd7c3
f73c22a
0c7175b
a363f45
abd1fc8
84ce2e8
6144d98
7f98414
a7e9139
4cf576b
0b3b30e
7e46f7d
a6b6281
6731b1d
8b3e5e9
6bb875c
b1082a5
3a1c9c8
9e833d8
b22588f
a57f829
d1c9416
b211652
fd377e8
781fccc
7133fa1
5b8af37
833bda2
b3eb1fd
ccb11cc
8166675
a81d18e
280b254
4e391f8
e62ed10
8aa31a0
20fa939
eba128a
944a67c
74d252b
ac700af
71720a3
d9c00c2
6970446
8810300
254fadc
3fec131
e3db12c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Verify Invitation | ||
|
|
||
| Verify an invitation token from a magic link email and log the user in. | ||
|
|
||
| ## Query Parameters | ||
|
|
||
| - **token** (required): The invitation JWT token from the email link. | ||
|
|
||
| ## Behavior | ||
|
|
||
| 1. Validates the invitation token (checks signature, expiry, and type). | ||
| 2. Looks up the user by the email embedded in the token. | ||
| 3. If the user exists and is inactive (first login), activates the account. | ||
| 4. Returns a JWT access token with the org/project from the invitation embedded. | ||
| 5. Sets `access_token` and `refresh_token` as HTTP-only cookies. | ||
|
|
||
| ## Error Responses | ||
|
|
||
| - **400**: Invalid or expired invitation link. | ||
| - **404**: User account not found. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>You're invited to {{ project_name }}</title> | ||
| </head> | ||
| <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f4f4f5;"> | ||
| <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"> | ||
| <tr> | ||
| <td style="padding: 60px 20px;"> | ||
| <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 520px; margin: 0 auto;"> | ||
| <tr> | ||
| <td align="center" style="background-color: #ffffff; padding: 48px 40px; text-align: center;"> | ||
| <h1 style="margin: 0 0 4px; font-size: 22px; font-weight: 600; color: #18181b;"> | ||
| Kaapi Konsole | ||
| </h1> | ||
| <p style="margin: 0 0 20px; font-size: 14px; color: #a1a1aa;"> | ||
| {{ organization_name }} | ||
| </p> | ||
| <table role="presentation" width="40" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto 20px;"> | ||
| <tr> | ||
| <td style="border-top: 2px solid #e4e4e7;"></td> | ||
| </tr> | ||
| </table> | ||
| <p style="margin: 0 0 5px; font-size: 15px; line-height: 1.7; color: #3f3f46;"> | ||
| You have been invited to join the <strong>{{ project_name }}</strong> project on {{ app_name }}. | ||
| </p> | ||
| <p style="margin: 0 0 20px; font-size: 15px; line-height: 1.7; color: #3f3f46;"> | ||
| Click the button below to accept the invitation and get started. | ||
| </p> | ||
| <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;"> | ||
| <tr> | ||
| <td align="center" style="background-color: #2563eb; border-radius: 10px;"> | ||
| <a href="{{ link }}" target="_blank" style="display: inline-block; padding: 10px 30px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;"> | ||
| Accept Invitation | ||
| </a> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| <p style="margin: 20px 0 0; font-size: 12px; color: #a1a1aa; line-height: 1.6;"> | ||
| This invitation expires in {{ valid_days }} days.<br> | ||
| If you did not expect this invitation, you can safely ignore this email. | ||
| </p> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,13 +6,18 @@ | |
|
|
||
| from app.core.config import settings | ||
| from app.core.security import create_access_token, create_refresh_token | ||
| from app.services.auth import ( | ||
| generate_invite_token, | ||
| verify_invite_token, | ||
| ) | ||
| from app.tests.utils.auth import TestAuthContext | ||
| from app.tests.utils.user import create_random_user | ||
|
|
||
| GOOGLE_AUTH_URL = f"{settings.API_V1_STR}/auth/google" | ||
| SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project" | ||
| REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh" | ||
| LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout" | ||
| INVITE_VERIFY_URL = f"{settings.API_V1_STR}/auth/invite/verify" | ||
|
|
||
| MOCK_GOOGLE_PROFILE = { | ||
| "email": None, # set per test | ||
|
|
@@ -107,9 +112,6 @@ def test_google_auth_activates_inactive_user( | |
| resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) | ||
| assert resp.status_code == 200 | ||
|
|
||
| db.refresh(user) | ||
| assert user.is_active is True | ||
|
|
||
| @patch("app.api.routes.auth.id_token.verify_oauth2_token") | ||
| @patch("app.api.routes.auth.settings") | ||
| def test_google_auth_success_no_projects( | ||
|
|
@@ -298,3 +300,75 @@ def test_logout_success(self, client: TestClient): | |
| body = resp.json() | ||
| assert body["success"] is True | ||
| assert body["data"]["message"] == "Logged out successfully" | ||
|
|
||
|
|
||
| class TestInviteVerify: | ||
| """Test suite for GET /auth/invite/verify endpoint.""" | ||
|
|
||
| def test_verify_invalid_token(self, client: TestClient): | ||
| """Test returns 400 for invalid invite token.""" | ||
| resp = client.get(f"{INVITE_VERIFY_URL}?token=invalid.token") | ||
| assert resp.status_code == 400 | ||
|
|
||
| def test_verify_user_not_found(self, client: TestClient): | ||
| """Test returns 404 when invited user doesn't exist.""" | ||
| token = generate_invite_token( | ||
| email="ghost@example.com", organization_id=1, project_id=1 | ||
| ) | ||
| resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") | ||
| assert resp.status_code == 404 | ||
|
|
||
| def test_verify_activates_inactive_user( | ||
| self, db: Session, client: TestClient, user_api_key: TestAuthContext | ||
| ): | ||
| """Test invite verification activates inactive user.""" | ||
| user = create_random_user(db) | ||
| user.is_active = False | ||
| db.add(user) | ||
| db.commit() | ||
| db.refresh(user) | ||
|
|
||
| token = generate_invite_token( | ||
| email=user.email, | ||
| organization_id=user_api_key.organization.id, | ||
| project_id=user_api_key.project.id, | ||
| ) | ||
| resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") | ||
| assert resp.status_code == 200 | ||
|
|
||
| db.refresh(user) | ||
| assert user.is_active is True | ||
| assert "access_token" in resp.json()["data"] | ||
|
|
||
| def test_verify_success_active_user( | ||
| self, db: Session, client: TestClient, user_api_key: TestAuthContext | ||
| ): | ||
| """Test invite verification works for already active user.""" | ||
| user = create_random_user(db) | ||
| token = generate_invite_token( | ||
| email=user.email, | ||
| organization_id=user_api_key.organization.id, | ||
| project_id=user_api_key.project.id, | ||
| ) | ||
| resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") | ||
| assert resp.status_code == 200 | ||
| assert "access_token" in resp.json()["data"] | ||
|
|
||
|
|
||
| class TestTokenGeneration: | ||
| """Test suite for services/auth.py token generation functions.""" | ||
|
|
||
| def test_generate_and_verify_invite_token(self): | ||
| """Test invite token roundtrip.""" | ||
| token = generate_invite_token( | ||
| email="test@example.com", organization_id=1, project_id=2 | ||
| ) | ||
| result = verify_invite_token(token) | ||
| assert result is not None | ||
| assert result["email"] == "test@example.com" | ||
| assert result["organization_id"] == 1 | ||
| assert result["project_id"] == 2 | ||
|
|
||
| def test_verify_invite_token_invalid(self): | ||
| """Test invite verify returns None for garbage tokens.""" | ||
| assert verify_invite_token("garbage") is None | ||
|
Comment on lines
+305
to
+374
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify defs without return type annotations in this file.
rg -nP '^\s*def\s+\w+\([^)]*\):\s*$' backend/app/tests/api/test_auth.pyRepository: ProjectTech4DevAI/kaapi-backend Length of output: 922 🏁 Script executed: #!/bin/bash
# Extract the exact content from lines 305-374 to see complete method definitions
sed -n '305,374p' backend/app/tests/api/test_auth.pyRepository: ProjectTech4DevAI/kaapi-backend Length of output: 2747 Add return type annotations ( The 6 test methods lack explicit
Proposed diff class TestInviteVerify:
"""Test suite for GET /auth/invite/verify endpoint."""
def test_verify_invalid_token(self, client: TestClient):
+ def test_verify_invalid_token(self, client: TestClient) -> None:
"""Test returns 400 for invalid invite token."""
def test_verify_user_not_found(self, client: TestClient):
+ def test_verify_user_not_found(self, client: TestClient) -> None:
"""Test returns 404 when invited user doesn't exist."""
def test_verify_activates_inactive_user(
self, db: Session, client: TestClient, user_api_key: TestAuthContext
):
+ ) -> None:
"""Test invite verification activates inactive user."""
def test_verify_success_active_user(
self, db: Session, client: TestClient, user_api_key: TestAuthContext
):
+ ) -> None:
"""Test invite verification works for already active user."""
class TestTokenGeneration:
"""Test suite for services/auth.py token generation functions."""
def test_generate_and_verify_invite_token(self):
+ def test_generate_and_verify_invite_token(self) -> None:
"""Test invite token roundtrip."""
def test_verify_invite_token_invalid(self):
+ def test_verify_invite_token_invalid(self) -> None:
"""Test invite verify returns None for garbage tokens."""🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-check current project access before issuing the scoped JWT.
Line 236 builds the session directly from
invite_data["organization_id"]/["project_id"]. If that invite is later revoked or the user-project mapping changes after the email is sent, this endpoint still mints a token for the stale project. Please resolve current access from the DB first, likeselect_projectalready does on Lines 142-156, and reject stale invites.Suggested guard
user = get_user_by_email(session=session, email=invite_data["email"]) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User account not found. Please contact support.", ) + available_projects = get_user_accessible_projects(session=session, user_id=user.id) + matching = [ + p + for p in available_projects + if p["organization_id"] == invite_data["organization_id"] + and p["project_id"] == invite_data["project_id"] + ] + if not matching: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invitation is no longer valid", + ) + # Activate user if not already active if not user.is_active: user.is_active = True session.add(user) session.commit() session.refresh(user) logger.info( f"[verify_invitation] User activated via invite | user_id: {user.id}" ) + proj = matching[0] response = build_token_response( user_id=user.id, - organization_id=invite_data["organization_id"], - project_id=invite_data["project_id"], + organization_id=proj["organization_id"], + project_id=proj["project_id"], )🤖 Prompt for AI Agents