Skip to content

Commit ffc85d9

Browse files
committed
Add auth email verification and reset JSON APIs
1 parent 3e744c0 commit ffc85d9

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

insforge/auth/client.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from .._base_client import BaseClient
77
from .._utils import quote_path_segment
88
from ..exceptions import InsforgeAuthError
9+
from .models import AuthEmailActionResponse
10+
from .models import AuthEmailVerificationRequest
11+
from .models import AuthEmailVerifyRequest
912
from .models import AdminSessionExchangeRequest
1013
from .models import AnonymousTokenResponse
1114
from .models import AuthConfigResponse
@@ -15,6 +18,9 @@
1518
from .models import AuthDeleteUsersResponse
1619
from .models import AuthSessionResponse
1720
from .models import AuthUserCreateRequest
21+
from .models import AuthResetPasswordExchangeRequest
22+
from .models import AuthResetPasswordExchangeResponse
23+
from .models import AuthResetPasswordRequest
1824
from .models import AuthUsersResponse
1925
from .models import CurrentProfileResponse
2026
from .models import LogoutResponse
@@ -47,6 +53,40 @@ async def sign_in_with_password(
4753
)
4854
return SignInResponse.model_validate(payload)
4955

56+
async def send_email_verification(
57+
self,
58+
*,
59+
email: str,
60+
redirect_to: str | None = None,
61+
) -> AuthEmailActionResponse:
62+
payload = AuthEmailVerificationRequest(
63+
email=email,
64+
redirect_to=redirect_to,
65+
).model_dump(by_alias=True, exclude_none=True)
66+
response = await self._client._request_json(
67+
"POST",
68+
"/api/auth/email/send-verification",
69+
json=payload,
70+
exception_cls=InsforgeAuthError,
71+
)
72+
return AuthEmailActionResponse.model_validate(response)
73+
74+
async def verify_email(
75+
self,
76+
*,
77+
email: str,
78+
otp: str,
79+
) -> AuthSessionResponse:
80+
payload = AuthEmailVerifyRequest(email=email, otp=otp).model_dump(by_alias=True)
81+
response = await self._client._request_json(
82+
"POST",
83+
"/api/auth/email/verify",
84+
params=SERVER_CLIENT_TYPE_PARAMS,
85+
json=payload,
86+
exception_cls=InsforgeAuthError,
87+
)
88+
return AuthSessionResponse.model_validate(response)
89+
5090
async def get_public_config(self) -> PublicAuthConfigResponse:
5191
payload = await self._client._request_json("GET", "/api/auth/public-config")
5292
return PublicAuthConfigResponse.model_validate(payload)
@@ -178,6 +218,57 @@ async def refresh(
178218
)
179219
return AuthSessionResponse.model_validate(payload)
180220

221+
async def send_reset_password_email(
222+
self,
223+
*,
224+
email: str,
225+
redirect_to: str | None = None,
226+
) -> AuthEmailActionResponse:
227+
payload = AuthEmailVerificationRequest(
228+
email=email,
229+
redirect_to=redirect_to,
230+
).model_dump(by_alias=True, exclude_none=True)
231+
response = await self._client._request_json(
232+
"POST",
233+
"/api/auth/email/send-reset-password",
234+
json=payload,
235+
exception_cls=InsforgeAuthError,
236+
)
237+
return AuthEmailActionResponse.model_validate(response)
238+
239+
async def exchange_reset_password_token(
240+
self,
241+
*,
242+
email: str,
243+
code: str,
244+
) -> AuthResetPasswordExchangeResponse:
245+
payload = AuthResetPasswordExchangeRequest(email=email, code=code).model_dump(by_alias=True)
246+
response = await self._client._request_json(
247+
"POST",
248+
"/api/auth/email/exchange-reset-password-token",
249+
json=payload,
250+
exception_cls=InsforgeAuthError,
251+
)
252+
return AuthResetPasswordExchangeResponse.model_validate(response)
253+
254+
async def reset_password(
255+
self,
256+
*,
257+
new_password: str,
258+
otp: str,
259+
) -> AuthEmailActionResponse:
260+
payload = AuthResetPasswordRequest(new_password=new_password, otp=otp).model_dump(
261+
by_alias=True,
262+
exclude_none=True,
263+
)
264+
response = await self._client._request_json(
265+
"POST",
266+
"/api/auth/email/reset-password",
267+
json=payload,
268+
exception_cls=InsforgeAuthError,
269+
)
270+
return AuthEmailActionResponse.model_validate(response)
271+
181272
async def logout(self) -> LogoutResponse:
182273
payload = await self._client._request_json("POST", "/api/auth/logout")
183274
return LogoutResponse.model_validate(payload)

insforge/auth/models.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,48 @@ class SignInResponse(BaseModel):
1313
refresh_token: str = Field(alias="refreshToken")
1414

1515

16+
class AuthEmailActionResponse(BaseModel):
17+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
18+
19+
success: bool | None = None
20+
message: str | None = None
21+
22+
23+
class AuthEmailVerificationRequest(BaseModel):
24+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
25+
26+
email: str
27+
redirect_to: str | None = Field(default=None, alias="redirectTo")
28+
29+
30+
class AuthEmailVerifyRequest(BaseModel):
31+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
32+
33+
email: str
34+
otp: str
35+
36+
37+
class AuthResetPasswordExchangeRequest(BaseModel):
38+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
39+
40+
email: str
41+
code: str
42+
43+
44+
class AuthResetPasswordExchangeResponse(BaseModel):
45+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
46+
47+
token: str | None = None
48+
expires_at: datetime | None = Field(default=None, alias="expiresAt")
49+
50+
51+
class AuthResetPasswordRequest(BaseModel):
52+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
53+
54+
new_password: str = Field(alias="newPassword")
55+
otp: str
56+
57+
1658
class CurrentProfileResponse(BaseModel):
1759
model_config = ConfigDict(extra="ignore", populate_by_name=True)
1860

tests/auth/test_auth_client.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,119 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
424424

425425
assert exc_info.value.error == "UNAUTHORIZED"
426426
assert exc_info.value.message == "Invalid credentials"
427+
428+
429+
def test_email_verification_and_reset_password_json_endpoints_request_expected_payloads() -> None:
430+
async def scenario() -> tuple[list[dict[str, object]], object, object, object, object, object]:
431+
calls: list[dict[str, object]] = []
432+
433+
async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response:
434+
calls.append({"method": method, "url": str(url), "kwargs": kwargs})
435+
436+
if str(url).endswith("/api/auth/email/send-verification"):
437+
return httpx.Response(202, json={"success": True, "message": "Verification email sent"})
438+
439+
if str(url).endswith("/api/auth/email/verify"):
440+
return httpx.Response(
441+
200,
442+
json={
443+
"user": {
444+
"id": "u1",
445+
"email": "a@example.com",
446+
"profile": {"name": "Ada"},
447+
"emailVerified": True,
448+
},
449+
"accessToken": "access",
450+
"refreshToken": "refresh",
451+
},
452+
)
453+
454+
if str(url).endswith("/api/auth/email/send-reset-password"):
455+
return httpx.Response(202, json={"success": True, "message": "Reset email sent"})
456+
457+
if str(url).endswith("/api/auth/email/exchange-reset-password-token"):
458+
return httpx.Response(200, json={"token": "reset-token", "expiresAt": "2026-03-28T12:34:56Z"})
459+
460+
return httpx.Response(200, json={"message": "Password reset successfully"})
461+
462+
async with InsforgeClient(
463+
base_url="https://example.com",
464+
api_key="ins_test",
465+
) as client:
466+
client.http_client.request = fake_request # type: ignore[method-assign]
467+
send_verification = await client.auth.send_email_verification(
468+
email="a@example.com",
469+
redirect_to="https://app.example.com/sign-in",
470+
)
471+
verify = await client.auth.verify_email(
472+
email="a@example.com",
473+
otp="123456",
474+
)
475+
send_reset = await client.auth.send_reset_password_email(
476+
email="a@example.com",
477+
redirect_to="https://app.example.com/reset-password",
478+
)
479+
exchange = await client.auth.exchange_reset_password_token(
480+
email="a@example.com",
481+
code="123456",
482+
)
483+
reset = await client.auth.reset_password(
484+
new_password="new-password",
485+
otp="reset-token",
486+
)
487+
488+
return calls, send_verification, verify, send_reset, exchange, reset
489+
490+
calls, send_verification, verify, send_reset, exchange, reset = asyncio.run(scenario())
491+
492+
assert calls[0]["method"] == "POST"
493+
assert calls[0]["url"] == "https://example.com/api/auth/email/send-verification"
494+
assert calls[0]["kwargs"]["json"] == {
495+
"email": "a@example.com",
496+
"redirectTo": "https://app.example.com/sign-in",
497+
}
498+
assert calls[0]["kwargs"]["headers"]["X-API-Key"] == "ins_test"
499+
assert "Authorization" not in calls[0]["kwargs"]["headers"]
500+
501+
assert calls[1]["method"] == "POST"
502+
assert calls[1]["url"] == "https://example.com/api/auth/email/verify"
503+
assert calls[1]["kwargs"]["params"] == {"client_type": "server"}
504+
assert calls[1]["kwargs"]["json"] == {"email": "a@example.com", "otp": "123456"}
505+
assert calls[1]["kwargs"]["headers"]["X-API-Key"] == "ins_test"
506+
assert "Authorization" not in calls[1]["kwargs"]["headers"]
507+
508+
assert calls[2]["method"] == "POST"
509+
assert calls[2]["url"] == "https://example.com/api/auth/email/send-reset-password"
510+
assert calls[2]["kwargs"]["json"] == {
511+
"email": "a@example.com",
512+
"redirectTo": "https://app.example.com/reset-password",
513+
}
514+
assert calls[2]["kwargs"]["headers"]["X-API-Key"] == "ins_test"
515+
assert "Authorization" not in calls[2]["kwargs"]["headers"]
516+
517+
assert calls[3]["method"] == "POST"
518+
assert calls[3]["url"] == "https://example.com/api/auth/email/exchange-reset-password-token"
519+
assert calls[3]["kwargs"]["json"] == {"email": "a@example.com", "code": "123456"}
520+
assert calls[3]["kwargs"]["headers"]["X-API-Key"] == "ins_test"
521+
assert "Authorization" not in calls[3]["kwargs"]["headers"]
522+
523+
assert calls[4]["method"] == "POST"
524+
assert calls[4]["url"] == "https://example.com/api/auth/email/reset-password"
525+
assert calls[4]["kwargs"]["json"] == {"newPassword": "new-password", "otp": "reset-token"}
526+
assert calls[4]["kwargs"]["headers"]["X-API-Key"] == "ins_test"
527+
assert "Authorization" not in calls[4]["kwargs"]["headers"]
528+
529+
assert send_verification.success is True
530+
assert send_verification.message == "Verification email sent"
531+
532+
assert verify.user.email == "a@example.com"
533+
assert verify.access_token == "access"
534+
assert verify.refresh_token == "refresh"
535+
536+
assert send_reset.success is True
537+
assert send_reset.message == "Reset email sent"
538+
539+
assert exchange.token == "reset-token"
540+
assert exchange.expires_at.isoformat() == "2026-03-28T12:34:56+00:00"
541+
542+
assert reset.message == "Password reset successfully"

0 commit comments

Comments
 (0)