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
4 changes: 3 additions & 1 deletion descope/authmethod/_webauthn_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict:
return {"transactionId": transaction_id, "response": response}

@staticmethod
def _compose_update_start_body(login_id: str, origin: str) -> dict:
def _compose_update_start_body(login_id: str, origin: str, mfa: bool = False) -> dict:
body: dict = {"loginId": login_id}
if origin:
body["origin"] = origin
if mfa:
body["mfa"] = mfa
return body

@staticmethod
Expand Down
38 changes: 32 additions & 6 deletions descope/authmethod/webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,51 @@ def sign_up_or_in_start(
response = self._http.post(uri, body=body)
return response.json()

def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict:
def update_start(
self,
login_id: str,
refresh_token: str,
origin: str,
mfa: bool = False,
) -> dict:
"""
Docs
Start adding a new WebAuthn authenticator (passkey) to an existing user.

Pass mfa=True so that update_finish returns a single session whose amr merges the user's
previously-passed factors with the new passkey, instead of having to run a separate
sign-in afterwards.
"""
self._validate_login_id(login_id)
self._validate_refresh_token(refresh_token)

uri = EndpointsV1.update_auth_webauthn_start_path
body = self._compose_update_start_body(login_id, origin)
body = self._compose_update_start_body(login_id, origin, mfa)
response = self._http.post(uri, body=body, pswd=refresh_token)
return response.json()

def update_finish(self, transaction_id: str, response: str) -> None:
def update_finish(
self,
transaction_id: str,
response: str,
audience: Union[str, None, Iterable[str]] = None,
) -> Optional[dict]:
"""
Docs
Complete adding a new WebAuthn authenticator (passkey) to an existing user.

When the matching update_start opted into mfa/stepup, returns a JWT response whose amr
merges the prior factors with the new passkey; otherwise returns None (the credential is
enrolled but no new session is minted).
"""
self._validate_transaction_id(transaction_id)
self._validate_response(response)

uri = EndpointsV1.update_auth_webauthn_finish_path
body = self._compose_update_finish_body(transaction_id, response)
self._http.post(uri, body=body)
http_response = self._http.post(uri, body=body)
resp = http_response.json()
jwt = resp.get("jwt") if isinstance(resp, dict) else None
if not jwt:
return None
return self._auth.generate_jwt_response(
jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience
)
38 changes: 32 additions & 6 deletions descope/authmethod/webauthn_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,21 +103,47 @@ async def sign_up_or_in_start(
response = await self._http.post(uri, body=body)
return response.json()

async def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict:
"""Start adding a new WebAuthn authenticator to an existing user."""
async def update_start(
self,
login_id: str,
refresh_token: str,
origin: str,
mfa: bool = False,
) -> dict:
"""Start adding a new WebAuthn authenticator (passkey) to an existing user.

Pass mfa=True so that update_finish returns a single session whose amr merges the user's
previously-passed factors with the new passkey.
"""
self._validate_login_id(login_id)
self._validate_refresh_token(refresh_token)

uri = EndpointsV1.update_auth_webauthn_start_path
body = self._compose_update_start_body(login_id, origin)
body = self._compose_update_start_body(login_id, origin, mfa)
response = await self._http.post(uri, body=body, pswd=refresh_token)
return response.json()

async def update_finish(self, transaction_id: str, response: str) -> None:
"""Complete adding a new WebAuthn authenticator to an existing user."""
async def update_finish(
self,
transaction_id: str,
response: str,
audience: Union[str, None, Iterable[str]] = None,
) -> Optional[dict]:
"""Complete adding a new WebAuthn authenticator (passkey) to an existing user.

When the matching update_start opted into mfa/stepup, returns a JWT response whose amr
merges the prior factors with the new passkey; otherwise returns None.
"""
self._validate_transaction_id(transaction_id)
self._validate_response(response)

uri = EndpointsV1.update_auth_webauthn_finish_path
body = self._compose_update_finish_body(transaction_id, response)
await self._http.post(uri, body=body)
http_response = await self._http.post(uri, body=body)
resp = http_response.json()
jwt = resp.get("jwt") if isinstance(resp, dict) else None
if not jwt:
return None
return await self._auth.prepare_jwt_response(
jwt, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience
)
40 changes: 40 additions & 0 deletions tests/test_webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ def test_compose_update_start_body(self):
"loginId": "dummy@dummy.com",
"origin": "https://example.com",
}
# mfa is included only when set
assert WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com", mfa=True) == {
"loginId": "dummy@dummy.com",
"origin": "https://example.com",
"mfa": True,
}

async def test_sign_up_start(self, client_factory):
client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT)
Expand Down Expand Up @@ -323,3 +329,37 @@ async def test_update_finish(self, client_factory):
json={"transactionId": "t01", "response": "resp01"},
follow_redirects=False,
)

async def test_update_start_with_mfa(self, client_factory):
client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT)
refresh_token = VALID_REFRESH_TOKEN
with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post:
await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com", mfa=True))
assert_http_called(
mock_post,
client.mode,
f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}",
headers={
**common.default_headers,
"Authorization": f"Bearer {PROJECT_ID}:{refresh_token}",
"x-descope-project-id": PROJECT_ID,
},
params=None,
json={
"loginId": "id1",
"origin": "https://example.com",
"mfa": True,
},
follow_redirects=False,
)

async def test_update_finish_with_mfa(self, client_factory):
# mfa enrollment: finish returns the merged-amr session nested under "jwt"
client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT)
success_resp = make_response(
{"jwt": {"sessionJwt": VALID_SESSION_TOKEN}},
cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN},
)
with client.mock_post(success_resp):
result = await client.invoke(client.webauthn.update_finish("t01", "resp01"))
assert result is not None
Loading