Skip to content

Commit 45f2f1f

Browse files
committed
new feature- reset mfa implementation
1 parent f85a8de commit 45f2f1f

File tree

4 files changed

+317
-0
lines changed

4 files changed

+317
-0
lines changed

MFA_WITHDRAW_FEATURE.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# MFA Withdraw Feature Implementation
2+
3+
This document describes the implementation of the `withdraw_mfa_enrollment` feature for the Firebase Admin SDK for Python.
4+
5+
## Overview
6+
7+
The `withdraw_mfa_enrollment` function allows administrators to programmatically withdraw (reset) a user's enrolled second factor authentication method. This feature was previously available in the Node.js SDK but missing from the Python SDK.
8+
9+
## Implementation Details
10+
11+
### Files Modified/Created
12+
13+
1. **`firebase_admin/_mfa.py`** - New module containing the core MFA functionality
14+
2. **`firebase_admin/auth.py`** - Updated to export the new function and MfaError
15+
3. **`tests/test_mfa_withdraw.py`** - Comprehensive test suite
16+
17+
### Key Components
18+
19+
#### Core Function: `withdraw_mfa_enrollment`
20+
21+
```python
22+
def withdraw_mfa_enrollment(
23+
uid: str,
24+
mfa_enrollment_id: str,
25+
api_key: str,
26+
tenant_id: str | None = None,
27+
app=None
28+
) -> dict:
29+
```
30+
31+
**Parameters:**
32+
- `uid`: Firebase Auth UID of the user
33+
- `mfa_enrollment_id`: The MFA enrollment ID to revoke
34+
- `api_key`: Web API key from Firebase project settings
35+
- `tenant_id`: Optional tenant ID for multi-tenancy
36+
- `app`: Optional Firebase app instance
37+
38+
**Returns:** Dictionary response from the Identity Toolkit API
39+
40+
**Raises:**
41+
- `MfaError`: If the operation fails
42+
- `ValueError`: For invalid arguments
43+
44+
#### Implementation Flow
45+
46+
1. **Create Custom Token**: Uses the Firebase Admin SDK to mint a custom token for the user
47+
2. **Exchange for ID Token**: Calls the Identity Toolkit `signInWithCustomToken` endpoint
48+
3. **Withdraw MFA**: Uses the ID token to call the `mfaEnrollment:withdraw` endpoint
49+
50+
#### Error Handling
51+
52+
- Custom `MfaError` exception for MFA-specific failures
53+
- Proper HTTP error handling with detailed error messages
54+
- Input validation for required parameters
55+
56+
## Usage Example
57+
58+
```python
59+
import firebase_admin
60+
from firebase_admin import auth, credentials
61+
62+
# Initialize the SDK
63+
cred = credentials.Certificate("service-account-key.json")
64+
firebase_admin.initialize_app(cred)
65+
66+
# Withdraw MFA enrollment
67+
try:
68+
result = auth.withdraw_mfa_enrollment(
69+
uid="user123",
70+
mfa_enrollment_id="enrollment456",
71+
api_key="your-web-api-key"
72+
)
73+
print("MFA withdrawn successfully:", result)
74+
except auth.MfaError as e:
75+
print("MFA operation failed:", e)
76+
```
77+
78+
## Testing
79+
80+
The implementation includes comprehensive tests covering:
81+
- Successful withdrawal scenarios
82+
- Error handling for API failures
83+
- Input validation
84+
- Integration with the auth module
85+
86+
Run tests with:
87+
```bash
88+
python -m pytest tests/test_mfa_withdraw.py -v
89+
```
90+
91+
## API Compatibility
92+
93+
This implementation follows the same pattern as the Node.js SDK, ensuring consistency across Firebase Admin SDKs.
94+
95+
## Next Steps
96+
97+
1. **Integration Testing**: Test with actual Firebase project
98+
2. **Documentation**: Add to official SDK documentation
99+
3. **Code Review**: Submit for Firebase team review
100+
4. **Release**: Include in next SDK version
101+
102+
## Notes
103+
104+
- Requires Web API key (different from service account key)
105+
- Uses Identity Toolkit v2 API endpoints
106+
- Supports multi-tenant projects via `tenant_id` parameter
107+
- Follows existing SDK patterns for error handling and app management

firebase_admin/_mfa.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright 2025 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Firebase auth MFA management sub module."""
16+
17+
import requests
18+
import typing as _t
19+
20+
from firebase_admin import _auth_client
21+
from firebase_admin import _utils
22+
from firebase_admin import exceptions
23+
24+
_AUTH_ATTRIBUTE = '_auth'
25+
26+
class MfaError(exceptions.FirebaseError):
27+
"""Represents an error related to MFA operations."""
28+
def __init__(self, message, cause=None, http_response=None):
29+
exceptions.FirebaseError.__init__(self, 'MFA_ERROR', message, cause, http_response)
30+
31+
def _to_text(b: _t.Union[str, bytes]) -> str:
32+
return b.decode("utf-8") if isinstance(b, (bytes, bytearray)) else str(b)
33+
34+
35+
def _signin_with_custom_token(*, api_key: str, custom_token: str, tenant_id: str | None) -> str:
36+
"""
37+
Exchange a Custom Token for an ID token.
38+
39+
Uses: POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=API_KEY
40+
"""
41+
if not api_key:
42+
raise ValueError("api_key must be provided (Web API key from Firebase project settings).")
43+
44+
url = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key={api_key}"
45+
payload = {
46+
"token": custom_token,
47+
"returnSecureToken": True,
48+
}
49+
if tenant_id:
50+
payload["tenantId"] = tenant_id
51+
52+
try:
53+
r = requests.post(url, json=payload, timeout=30)
54+
r.raise_for_status()
55+
data = r.json()
56+
if "idToken" not in data:
57+
raise MfaError(f"Failed to exchange custom token for ID token: {data.get('error', 'Unknown error')}", http_response=r)
58+
return data["idToken"]
59+
except requests.exceptions.RequestException as e:
60+
message = f"Failed to exchange custom token for ID token: {e}"
61+
raise MfaError(message, cause=e, http_response=e.response) from e
62+
63+
64+
def withdraw_mfa_enrollment(
65+
*,
66+
uid: str,
67+
mfa_enrollment_id: str,
68+
api_key: str,
69+
tenant_id: str | None = None,
70+
app=None,
71+
) -> dict:
72+
"""
73+
Withdraw (reset) a user's enrolled second factor by enrollment ID.
74+
75+
Args:
76+
uid: Firebase Auth UID of the user to act on.
77+
mfa_enrollment_id: Enrollment ID of the second factor to revoke.
78+
api_key: Web API key (from Firebase console) used by signInWithCustomToken.
79+
tenant_id: Optional Tenant ID if using multi-tenancy.
80+
app: Optional firebase_admin App instance.
81+
82+
Returns:
83+
dict response from accounts.mfaEnrollment:withdraw (typically contains updated user info).
84+
85+
Raises:
86+
MfaError on failure.
87+
"""
88+
if not uid:
89+
raise ValueError("uid must be a non-empty string.")
90+
if not mfa_enrollment_id:
91+
raise ValueError("mfa_enrollment_id must be a non-empty string.")
92+
93+
# 1) Create Custom Token as the user
94+
client = _utils.get_app_service(app, _AUTH_ATTRIBUTE, _auth_client.Client)
95+
custom_token = _to_text(client.create_custom_token(uid))
96+
97+
# 2) Exchange Custom Token → ID token (requires API key)
98+
id_token = _signin_with_custom_token(api_key=api_key, custom_token=custom_token, tenant_id=tenant_id)
99+
100+
# 3) Withdraw MFA with the ID token
101+
withdraw_url = "https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:withdraw"
102+
if api_key:
103+
withdraw_url += f"?key={api_key}" # optional, but fine to append
104+
105+
payload = {"idToken": id_token, "mfaEnrollmentId": mfa_enrollment_id}
106+
if tenant_id:
107+
payload["tenantId"] = tenant_id
108+
109+
try:
110+
r = requests.post(withdraw_url, json=payload, timeout=30)
111+
r.raise_for_status()
112+
return r.json()
113+
except requests.exceptions.RequestException as e:
114+
message = f"Failed to withdraw MFA enrollment: {e}"
115+
raise MfaError(message, cause=e, http_response=e.response) from e

firebase_admin/auth.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from firebase_admin import _token_gen
2727
from firebase_admin import _user_import
2828
from firebase_admin import _user_mgt
29+
from firebase_admin import _mfa
2930
from firebase_admin import _utils
3031

3132

@@ -54,6 +55,7 @@
5455
'InvalidSessionCookieError',
5556
'ListProviderConfigsPage',
5657
'ListUsersPage',
58+
'MfaError',
5759
'OIDCProviderConfig',
5860
'PhoneNumberAlreadyExistsError',
5961
'ProviderConfig',
@@ -108,6 +110,7 @@
108110
'update_user',
109111
'verify_id_token',
110112
'verify_session_cookie',
113+
'withdraw_mfa_enrollment',
111114
]
112115

113116
ActionCodeSettings = _user_mgt.ActionCodeSettings
@@ -131,6 +134,7 @@
131134
InvalidSessionCookieError = _token_gen.InvalidSessionCookieError
132135
ListProviderConfigsPage = _auth_providers.ListProviderConfigsPage
133136
ListUsersPage = _user_mgt.ListUsersPage
137+
MfaError = _mfa.MfaError
134138
OIDCProviderConfig = _auth_providers.OIDCProviderConfig
135139
PhoneNumberAlreadyExistsError = _auth_utils.PhoneNumberAlreadyExistsError
136140
ProviderConfig = _auth_providers.ProviderConfig
@@ -647,6 +651,35 @@ def generate_sign_in_with_email_link(email, action_code_settings, app=None):
647651
email, action_code_settings=action_code_settings)
648652

649653

654+
def withdraw_mfa_enrollment(uid: str, mfa_enrollment_id: str, api_key: str, tenant_id: str | None = None, app=None) -> dict:
655+
"""Withdraw (reset) a second factor for the given user.
656+
657+
This performs an admin-initiated reset by minting a Custom Token for the user,
658+
exchanging it for an ID token, and then calling the Identity Toolkit withdraw API.
659+
660+
Args:
661+
uid: Firebase Auth UID.
662+
mfa_enrollment_id: The MFA enrollment ID to revoke (see accounts.lookup to find it).
663+
api_key: Web API key from your Firebase project settings.
664+
tenant_id: Optional Tenant ID for multi-tenancy.
665+
app: Optional App instance.
666+
667+
Returns:
668+
dict: Response from the withdraw call.
669+
670+
Raises:
671+
MfaError: If the operation fails.
672+
ValueError: For invalid arguments.
673+
"""
674+
return _mfa.withdraw_mfa_enrollment(
675+
uid=uid,
676+
mfa_enrollment_id=mfa_enrollment_id,
677+
api_key=api_key,
678+
tenant_id=tenant_id,
679+
app=app,
680+
)
681+
682+
650683
def get_oidc_provider_config(provider_id, app=None):
651684
"""Returns the ``OIDCProviderConfig`` with the given ID.
652685
@@ -924,3 +957,4 @@ def list_saml_provider_configs(
924957
"""
925958
client = _get_client(app)
926959
return client.list_saml_provider_configs(page_token, max_results)
960+

tests/test_mfa_withdraw.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# tests/test_mfa_withdraw.py
2+
from unittest import mock
3+
import pytest
4+
import firebase_admin
5+
from firebase_admin import auth
6+
from firebase_admin._mfa import withdraw_mfa_enrollment, MfaError
7+
from tests import testutils
8+
9+
API_KEY = "fake-api-key"
10+
UID = "uid123"
11+
ENROLL_ID = "enroll123"
12+
13+
@pytest.fixture(scope='module')
14+
def mfa_app():
15+
app = firebase_admin.initialize_app(
16+
testutils.MockCredential(), name='mfaTest', options={'projectId': 'mock-project-id'})
17+
yield app
18+
firebase_admin.delete_app(app)
19+
20+
def _fake_custom_token(uid):
21+
return b"FAKE.CUSTOM.TOKEN"
22+
23+
@mock.patch("firebase_admin._auth_client.Client.create_custom_token", side_effect=_fake_custom_token)
24+
@mock.patch("firebase_admin._mfa.requests.post")
25+
def test_withdraw_success(mock_post, _, mfa_app):
26+
# 1st call: signInWithCustomToken -> returns idToken
27+
# 2nd call: withdraw -> returns ok
28+
mock_post.side_effect = [
29+
mock.Mock(status_code=200, json=lambda: {"idToken": "ID.TOKEN"}),
30+
mock.Mock(status_code=200, json=lambda: {"localId": UID}),
31+
]
32+
res = withdraw_mfa_enrollment(uid=UID, mfa_enrollment_id=ENROLL_ID, api_key=API_KEY, app=mfa_app)
33+
assert res["localId"] == UID
34+
assert mock_post.call_count == 2
35+
36+
@mock.patch("firebase_admin._auth_client.Client.create_custom_token", side_effect=_fake_custom_token)
37+
@mock.patch("firebase_admin._mfa.requests.post")
38+
def test_withdraw_signin_fail(mock_post, _, mfa_app):
39+
mock_post.return_value = mock.Mock(status_code=400, json=lambda: {"error": {"message": "INVALID_CUSTOM_TOKEN"}})
40+
with pytest.raises(MfaError):
41+
withdraw_mfa_enrollment(uid=UID, mfa_enrollment_id=ENROLL_ID, api_key=API_KEY, app=mfa_app)
42+
43+
@mock.patch("firebase_admin._auth_client.Client.create_custom_token", side_effect=_fake_custom_token)
44+
@mock.patch("firebase_admin._mfa.requests.post")
45+
def test_withdraw_via_auth_module(mock_post, _, mfa_app):
46+
"""Test that the function is accessible via the auth module."""
47+
mock_post.side_effect = [
48+
mock.Mock(status_code=200, json=lambda: {"idToken": "ID.TOKEN"}),
49+
mock.Mock(status_code=200, json=lambda: {"localId": UID}),
50+
]
51+
res = auth.withdraw_mfa_enrollment(uid=UID, mfa_enrollment_id=ENROLL_ID, api_key=API_KEY, app=mfa_app)
52+
assert res["localId"] == UID
53+
assert mock_post.call_count == 2
54+
55+
def test_invalid_arguments():
56+
"""Test that invalid arguments raise ValueError."""
57+
with pytest.raises(ValueError, match="uid must be a non-empty string"):
58+
withdraw_mfa_enrollment(uid="", mfa_enrollment_id=ENROLL_ID, api_key=API_KEY)
59+
60+
with pytest.raises(ValueError, match="mfa_enrollment_id must be a non-empty string"):
61+
withdraw_mfa_enrollment(uid=UID, mfa_enrollment_id="", api_key=API_KEY)

0 commit comments

Comments
 (0)