Skip to content

Commit 3ce9675

Browse files
authored
Add password functions (#158)
* Add password functions * Add password fixes and samples * Fix magic link update URLs
1 parent 3aab322 commit 3ce9675

File tree

8 files changed

+1021
-2
lines changed

8 files changed

+1021
-2
lines changed

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,74 @@ refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
235235

236236
The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation)
237237

238+
### Passwords
239+
240+
The user can also authenticate with a password, though it's recommended to
241+
prefer passwordless authentication methods if possible. Sign up requires the
242+
caller to provide a valid password that meets all the requirements configured
243+
for the [password authentication method](https://app.descope.com/settings/authentication/password) in the Descope console.
244+
245+
```python
246+
# Every user must have a login_id and a password. All other user information is optional
247+
login_id = "[email protected]"
248+
password = "qYlvi65KaX"
249+
user = {
250+
"name": "Desmond Copeland",
251+
"email": login_id,
252+
}
253+
jwt_response = descope_client.password.sign_up(
254+
login_id=login_id,
255+
password=password,
256+
user=user,
257+
)
258+
session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
259+
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
260+
```
261+
262+
The user can later sign in using the same login_id and password.
263+
264+
```python
265+
jwt_response = descope_client.password.sign_in(login_id, password)
266+
session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
267+
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
268+
```
269+
270+
The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation)
271+
272+
In case the user needs to update their password, one of two methods are available: Resetting their password or replacing their password
273+
274+
**Changing Passwords**
275+
276+
_NOTE: send_reset will only work if the user has a validated email address. Otherwise password reset prompts cannot be sent._
277+
278+
In the [password authentication method](https://app.descope.com/settings/authentication/password) in the Descope console, it is possible to define which alternative authentication method can be used in order to authenticate the user, in order to reset and update their password.
279+
280+
```python
281+
# Start the reset process by sending a password reset prompt. In this example we'll assume
282+
# that magic link is configured as the reset method. The optional redirect URL is used in the
283+
# same way as in regular magic link authentication.
284+
login_id := "[email protected]"
285+
redirect_url := "https://myapp.com/password-reset"
286+
descope_client.password.send_reset(login_id, redirect_url)
287+
```
288+
289+
The magic link, in this case, must then be verified like any other magic link (see the [magic link section](#magic-link) for more details). However, after verifying the user, it is expected
290+
to allow them to provide a new password instead of the old one. Since the user is now authenticated, this is possible via:
291+
292+
```python
293+
# The refresh token is required to make sure the user is authenticated.
294+
err := descope_client.password.update(login_id, new_password, token)
295+
```
296+
297+
`update` can always be called when the user is authenticated and has a valid session.
298+
299+
Alternatively, it is also possible to replace an existing active password with a new one.
300+
301+
```python
302+
# Replaces the user's current password with a new one
303+
descope_client.password.replace(login_id, old_password, new_password)
304+
```
305+
238306
### Session Validation
239307

240308
Every secure request performed between your client and server needs to be validated. The client sends

descope/authmethod/magiclink.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def update_user_email(self, login_id: str, email: str, refresh_token: str) -> st
8484
Auth.validate_email(email)
8585

8686
body = MagicLink._compose_update_user_email_body(login_id, email)
87-
uri = EndpointsV1.update_user_email_otp_path
87+
uri = EndpointsV1.update_user_email_magiclink_path
8888
response = self._auth.do_post(uri, body, None, refresh_token)
8989
return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL)
9090

@@ -99,7 +99,7 @@ def update_user_phone(
9999
Auth.validate_phone(method, phone)
100100

101101
body = MagicLink._compose_update_user_phone_body(login_id, phone)
102-
uri = EndpointsV1.update_user_phone_otp_path
102+
uri = EndpointsV1.update_user_phone_magiclink_path
103103
response = self._auth.do_post(uri, body, None, refresh_token)
104104
return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS)
105105

descope/authmethod/password.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
from descope.auth import Auth
2+
from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1
3+
from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException
4+
5+
6+
class Password:
7+
_auth: Auth
8+
9+
def __init__(self, auth):
10+
self._auth = auth
11+
12+
def sign_up(self, login_id: str, password: str, user: dict = None) -> dict:
13+
"""
14+
Sign up (create) a new user using a login ID and password.
15+
(optional) Include additional user metadata that you wish to save.
16+
17+
Args:
18+
login_id (str): The login ID of the user being signed up
19+
password (str): The new user's password
20+
user (dict) optional: Preserve additional user metadata in the form of
21+
{"name": "Desmond Copeland", "phone": "2125551212", "email": "[email protected]"}
22+
23+
Return value (dict):
24+
Return dict in the format
25+
{"jwts": [], "user": "", "firstSeen": "", "error": ""}
26+
Includes all the jwts tokens (session token, refresh token), token claims, and user information
27+
28+
Raise:
29+
AuthException: raised if sign-up operation fails
30+
"""
31+
32+
if not login_id:
33+
raise AuthException(
34+
400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty"
35+
)
36+
37+
if not password:
38+
raise AuthException(
39+
400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty"
40+
)
41+
42+
uri = EndpointsV1.sign_up_password_path
43+
body = Password._compose_signup_body(login_id, password, user)
44+
response = self._auth.do_post(uri, body)
45+
46+
resp = response.json()
47+
jwt_response = self._auth.generate_jwt_response(
48+
resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None)
49+
)
50+
return jwt_response
51+
52+
def sign_in(
53+
self,
54+
login_id: str,
55+
password: str,
56+
) -> dict:
57+
"""
58+
Sign in by verifying the validity of a password entered by an end user.
59+
60+
Args:
61+
login_id (str): The login ID of the user being validated
62+
password (str): The password to be validated
63+
64+
Return value (dict):
65+
Return dict in the format
66+
{"jwts": [], "user": "", "firstSeen": "", "error": ""}
67+
Includes all the jwts tokens (session token, refresh token), token claims, and user information
68+
69+
Raise:
70+
AuthException: raised if sign in operation fails
71+
"""
72+
73+
if not login_id:
74+
raise AuthException(
75+
400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty"
76+
)
77+
78+
if not password:
79+
raise AuthException(
80+
400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty"
81+
)
82+
83+
uri = EndpointsV1.sign_in_password_path
84+
response = self._auth.do_post(uri, {"loginId": login_id, "password": password})
85+
86+
resp = response.json()
87+
jwt_response = self._auth.generate_jwt_response(
88+
resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None)
89+
)
90+
return jwt_response
91+
92+
def send_reset(
93+
self,
94+
login_id: str,
95+
redirect_url: str = None,
96+
) -> dict:
97+
"""
98+
Sends a password reset prompt to the user with the given
99+
login_id according to the password settings defined in the Descope console.
100+
NOTE: The user must be verified according to the configured password reset method.
101+
102+
Args:
103+
login_id (str): The login ID of the user to receive a password reset prompt
104+
redirect_url (str): Optional parameter that is used by Magic Link or Enchanted Link
105+
if those are the chosen reset methods. See the Magic Link and Enchanted Link sections
106+
for more details.
107+
108+
Return value (dict):
109+
Return dict in the format
110+
{"resetMethod": "", "pendingRef": "", "linkId": "", "maskedEmail": ""}
111+
The contents will differ according to the chosen reset method. 'pendingRef'
112+
and 'linkId' will only appear of 'resetMethod' == 'enchantedlink'
113+
114+
Raise:
115+
AuthException: raised if send reset operation fails
116+
"""
117+
118+
if not login_id:
119+
raise AuthException(
120+
400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty"
121+
)
122+
123+
uri = EndpointsV1.send_reset_password_path
124+
response = self._auth.do_post(
125+
uri, {"loginId": login_id, "redirectUrl": redirect_url}
126+
)
127+
128+
return response.json()
129+
130+
def update(self, login_id: str, new_password: str, refresh_token: str) -> None:
131+
"""
132+
Update a password for an existing logged in user using their refresh token.
133+
134+
Args:
135+
login_id (str): The login ID of the user whose information is being updated
136+
new_password (str): The new password to use
137+
refresh_token (str): The session's refresh token (used for verification)
138+
139+
Raise:
140+
AuthException: raised if refresh token is invalid or update operation fails
141+
"""
142+
143+
if not login_id:
144+
raise AuthException(
145+
400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty"
146+
)
147+
148+
if not new_password:
149+
raise AuthException(
150+
400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty"
151+
)
152+
153+
if not refresh_token:
154+
raise AuthException(
155+
400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty"
156+
)
157+
158+
uri = EndpointsV1.update_password_path
159+
self._auth.do_post(
160+
uri, {"loginId": login_id, "newPassword": new_password}, None, refresh_token
161+
)
162+
163+
def replace(self, login_id: str, old_password: str, new_password: str) -> None:
164+
"""
165+
Replace a valid active password with a new one. The old_password is used to
166+
authenticate the user. If the user cannot be authenticated, this operation
167+
will fail.
168+
169+
Args:
170+
login_id (str): The login ID of the user whose information is being updated
171+
old_password (str): The user's current active password
172+
new_password (str): The new password to use
173+
174+
Raise:
175+
AuthException: raised if replace operation fails
176+
"""
177+
178+
if not login_id:
179+
raise AuthException(
180+
400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty"
181+
)
182+
183+
if not old_password:
184+
raise AuthException(
185+
400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty"
186+
)
187+
188+
if not new_password:
189+
raise AuthException(
190+
400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty"
191+
)
192+
193+
uri = EndpointsV1.replace_password_path
194+
self._auth.do_post(
195+
uri,
196+
{
197+
"loginId": login_id,
198+
"oldPassword": old_password,
199+
"newPassword": new_password,
200+
},
201+
)
202+
203+
def get_policy(self) -> dict:
204+
"""
205+
Get a subset of the password policy defined in the Descope console and enforced
206+
by Descope. The goal is to enable client-side validations to give users a better UX
207+
208+
Return value (dict):
209+
Return dict in the format
210+
{"minLength": 8, "lowercase": true, "uppercase": true, "number": true, "nonAlphanumeric": true}
211+
minLength - the minimum length of a password
212+
lowercase - the password required at least one lowercase character
213+
uppercase - the password required at least one uppercase character
214+
number - the password required at least one number character
215+
nonAlphanumeric - the password required at least one non alphanumeric character
216+
217+
Raise:
218+
AuthException: raised if get policy operation fails
219+
"""
220+
221+
response = self._auth.do_get(EndpointsV1.password_policy_path)
222+
return response.json()
223+
224+
@staticmethod
225+
def _compose_signup_body(login_id: str, password: str, user: dict) -> dict:
226+
body = {"loginId": login_id, "password": password}
227+
if user is not None:
228+
body["user"] = user
229+
return body

descope/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ class EndpointsV1:
7676
update_auth_webauthn_start_path = "/v1/auth/webauthn/update/start"
7777
update_auth_webauthn_finish_path = "/v1/auth/webauthn/update/finish"
7878

79+
# password
80+
sign_up_password_path = "/v1/auth/password/signup"
81+
sign_in_password_path = "/v1/auth/password/signin"
82+
send_reset_password_path = "/v1/auth/password/reset"
83+
update_password_path = "/v1/auth/password/update"
84+
replace_password_path = "/v1/auth/password/replace"
85+
password_policy_path = "/v1/auth/password/policy"
86+
7987

8088
class EndpointsV2:
8189
public_key_path = "/v2/keys"

descope/descope_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from descope.authmethod.magiclink import MagicLink # noqa: F401
88
from descope.authmethod.oauth import OAuth # noqa: F401
99
from descope.authmethod.otp import OTP # noqa: F401
10+
from descope.authmethod.password import Password # noqa: F401
1011
from descope.authmethod.saml import SAML # noqa: F401
1112
from descope.authmethod.totp import TOTP # noqa: F401
1213
from descope.authmethod.webauthn import WebAuthn # noqa: F401
@@ -35,6 +36,7 @@ def __init__(
3536
self._otp = OTP(auth)
3637
self._totp = TOTP(auth)
3738
self._webauthn = WebAuthn(auth)
39+
self._password = Password(auth)
3840

3941
@property
4042
def mgmt(self):
@@ -72,6 +74,10 @@ def saml(self):
7274
def webauthn(self):
7375
return self._webauthn
7476

77+
@property
78+
def password(self):
79+
return self._password
80+
7581
def validate_permissions(self, jwt_response: dict, permissions: List[str]) -> bool:
7682
"""
7783
Validate that a jwt_response has been granted the specified permissions.

0 commit comments

Comments
 (0)