Skip to content

Commit 04c4069

Browse files
authored
feat(auth): Add auth emulator support via the FIREBASE_AUTH_EMULATOR_HOST environment variable. (#531)
* Support auth emulator via FIREBASE_AUTH_EMULATOR_HOST Modeled on firebase/firebase-admin-go#414 * Tests for emulator support in auth, user mgmt and token gen To minimize modification of tests, the app fixture and instrumentation have been modified to use a global dict of URLs, which are then monkey-patched based on fixture parameters. Essentially, all tests using the app fixture are run twice, once with the emulated endpoint and once without. * fallback for monkeypatch in python 3.5 * Token verification for the auth emulator * Accommodate auth emulator behaviour in tests. Where possible, tests are modified to account for the current behaviour in emulator mode (e.g., invalid or expired tokens or cookies still work). Fixtures were changed to function scope to avoid problems caused by overlap when some fixtures being in emulator mode and some in normal mode concurrently.
1 parent 3bdb182 commit 04c4069

File tree

12 files changed

+289
-102
lines changed

12 files changed

+289
-102
lines changed

firebase_admin/_auth_client.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from firebase_admin import _user_identifier
2525
from firebase_admin import _user_import
2626
from firebase_admin import _user_mgt
27+
from firebase_admin import _utils
2728

2829

2930
class Client:
@@ -36,18 +37,37 @@ def __init__(self, app, tenant_id=None):
3637
2. set the project ID explicitly via Firebase App options, or
3738
3. set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.""")
3839

39-
credential = app.credential.get_credential()
40+
credential = None
4041
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
4142
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
43+
# Non-default endpoint URLs for emulator support are set in this dict later.
44+
endpoint_urls = {}
45+
self.emulated = False
46+
47+
# If an emulator is present, check that the given value matches the expected format and set
48+
# endpoint URLs to use the emulator. Additionally, use a fake credential.
49+
emulator_host = _auth_utils.get_emulator_host()
50+
if emulator_host:
51+
base_url = 'http://{0}/identitytoolkit.googleapis.com'.format(emulator_host)
52+
endpoint_urls['v1'] = base_url + '/v1'
53+
endpoint_urls['v2beta1'] = base_url + '/v2beta1'
54+
credential = _utils.EmulatorAdminCredentials()
55+
self.emulated = True
56+
else:
57+
# Use credentials if provided
58+
credential = app.credential.get_credential()
59+
4260
http_client = _http_client.JsonHttpClient(
4361
credential=credential, headers={'X-Client-Version': version_header}, timeout=timeout)
4462

4563
self._tenant_id = tenant_id
46-
self._token_generator = _token_gen.TokenGenerator(app, http_client)
64+
self._token_generator = _token_gen.TokenGenerator(
65+
app, http_client, url_override=endpoint_urls.get('v1'))
4766
self._token_verifier = _token_gen.TokenVerifier(app)
48-
self._user_manager = _user_mgt.UserManager(http_client, app.project_id, tenant_id)
67+
self._user_manager = _user_mgt.UserManager(
68+
http_client, app.project_id, tenant_id, url_override=endpoint_urls.get('v1'))
4969
self._provider_manager = _auth_providers.ProviderConfigClient(
50-
http_client, app.project_id, tenant_id)
70+
http_client, app.project_id, tenant_id, url_override=endpoint_urls.get('v2beta1'))
5171

5272
@property
5373
def tenant_id(self):

firebase_admin/_auth_providers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ class ProviderConfigClient:
166166

167167
PROVIDER_CONFIG_URL = 'https://identitytoolkit.googleapis.com/v2beta1'
168168

169-
def __init__(self, http_client, project_id, tenant_id=None):
169+
def __init__(self, http_client, project_id, tenant_id=None, url_override=None):
170170
self.http_client = http_client
171-
self.base_url = '{0}/projects/{1}'.format(self.PROVIDER_CONFIG_URL, project_id)
171+
url_prefix = url_override or self.PROVIDER_CONFIG_URL
172+
self.base_url = '{0}/projects/{1}'.format(url_prefix, project_id)
172173
if tenant_id:
173174
self.base_url += '/tenants/{0}'.format(tenant_id)
174175

firebase_admin/_auth_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
"""Firebase auth utils."""
1616

1717
import json
18+
import os
1819
import re
1920
from urllib import parse
2021

2122
from firebase_admin import exceptions
2223
from firebase_admin import _utils
2324

2425

26+
EMULATOR_HOST_ENV_VAR = 'FIREBASE_AUTH_EMULATOR_HOST'
2527
MAX_CLAIMS_PAYLOAD_SIZE = 1000
2628
RESERVED_CLAIMS = set([
2729
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat',
@@ -66,6 +68,19 @@ def __iter__(self):
6668
return self
6769

6870

71+
def get_emulator_host():
72+
emulator_host = os.getenv(EMULATOR_HOST_ENV_VAR, '')
73+
if emulator_host and '//' in emulator_host:
74+
raise ValueError(
75+
'Invalid {0}: "{1}". It must follow format "host:port".'.format(
76+
EMULATOR_HOST_ENV_VAR, emulator_host))
77+
return emulator_host
78+
79+
80+
def is_emulated():
81+
return get_emulator_host() != ''
82+
83+
6984
def validate_uid(uid, required=False):
7085
if uid is None and not required:
7186
return None

firebase_admin/_token_gen.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@
5353
METADATA_SERVICE_URL = ('http://metadata.google.internal/computeMetadata/v1/instance/'
5454
'service-accounts/default/email')
5555

56+
# Emulator fake account
57+
AUTH_EMULATOR_EMAIL = 'firebase-auth-emulator@example.com'
58+
59+
60+
class _EmulatedSigner(google.auth.crypt.Signer):
61+
key_id = None
62+
63+
def __init__(self):
64+
pass
65+
66+
def sign(self, message):
67+
return b''
68+
5669

5770
class _SigningProvider:
5871
"""Stores a reference to a google.auth.crypto.Signer."""
@@ -78,21 +91,28 @@ def from_iam(cls, request, google_cred, service_account):
7891
signer = iam.Signer(request, google_cred, service_account)
7992
return _SigningProvider(signer, service_account)
8093

94+
@classmethod
95+
def for_emulator(cls):
96+
return _SigningProvider(_EmulatedSigner(), AUTH_EMULATOR_EMAIL)
97+
8198

8299
class TokenGenerator:
83100
"""Generates custom tokens and session cookies."""
84101

85102
ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1'
86103

87-
def __init__(self, app, http_client):
104+
def __init__(self, app, http_client, url_override=None):
88105
self.app = app
89106
self.http_client = http_client
90107
self.request = transport.requests.Request()
91-
self.base_url = '{0}/projects/{1}'.format(self.ID_TOOLKIT_URL, app.project_id)
108+
url_prefix = url_override or self.ID_TOOLKIT_URL
109+
self.base_url = '{0}/projects/{1}'.format(url_prefix, app.project_id)
92110
self._signing_provider = None
93111

94112
def _init_signing_provider(self):
95113
"""Initializes a signing provider by following the go/firebase-admin-sign protocol."""
114+
if _auth_utils.is_emulated():
115+
return _SigningProvider.for_emulator()
96116
# If the SDK was initialized with a service account, use it to sign bytes.
97117
google_cred = self.app.credential.get_credential()
98118
if isinstance(google_cred, google.oauth2.service_account.Credentials):
@@ -285,20 +305,22 @@ def verify(self, token, request):
285305
verify_id_token_msg = (
286306
'See {0} for details on how to retrieve {1}.'.format(self.url, self.short_name))
287307

308+
emulated = _auth_utils.is_emulated()
309+
288310
error_message = None
289311
if audience == FIREBASE_AUDIENCE:
290312
error_message = (
291313
'{0} expects {1}, but was given a custom '
292314
'token.'.format(self.operation, self.articled_short_name))
293-
elif not header.get('kid'):
315+
elif not emulated and not header.get('kid'):
294316
if header.get('alg') == 'HS256' and payload.get(
295317
'v') == 0 and 'uid' in payload.get('d', {}):
296318
error_message = (
297319
'{0} expects {1}, but was given a legacy custom '
298320
'token.'.format(self.operation, self.articled_short_name))
299321
else:
300322
error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name)
301-
elif header.get('alg') != 'RS256':
323+
elif not emulated and header.get('alg') != 'RS256':
302324
error_message = (
303325
'Firebase {0} has incorrect algorithm. Expected "RS256" but got '
304326
'"{1}". {2}'.format(self.short_name, header.get('alg'), verify_id_token_msg))
@@ -329,11 +351,14 @@ def verify(self, token, request):
329351
raise self._invalid_token_error(error_message)
330352

331353
try:
332-
verified_claims = google.oauth2.id_token.verify_token(
333-
token,
334-
request=request,
335-
audience=self.project_id,
336-
certs_url=self.cert_url)
354+
if emulated:
355+
verified_claims = payload
356+
else:
357+
verified_claims = google.oauth2.id_token.verify_token(
358+
token,
359+
request=request,
360+
audience=self.project_id,
361+
certs_url=self.cert_url)
337362
verified_claims['uid'] = verified_claims['sub']
338363
return verified_claims
339364
except google.auth.exceptions.TransportError as error:

firebase_admin/_user_mgt.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,9 +573,10 @@ class UserManager:
573573

574574
ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1'
575575

576-
def __init__(self, http_client, project_id, tenant_id=None):
576+
def __init__(self, http_client, project_id, tenant_id=None, url_override=None):
577577
self.http_client = http_client
578-
self.base_url = '{0}/projects/{1}'.format(self.ID_TOOLKIT_URL, project_id)
578+
url_prefix = url_override or self.ID_TOOLKIT_URL
579+
self.base_url = '{0}/projects/{1}'.format(url_prefix, project_id)
579580
if tenant_id:
580581
self.base_url += '/tenants/{0}'.format(tenant_id)
581582

firebase_admin/_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import json
1919
import socket
2020

21+
import google.auth
2122
import googleapiclient
2223
import httplib2
2324
import requests
@@ -339,3 +340,20 @@ def _parse_platform_error(content, status_code):
339340
if not msg:
340341
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content)
341342
return error_dict, msg
343+
344+
345+
# Temporarily disable the lint rule. For more information see:
346+
# https://github.com/googleapis/google-auth-library-python/pull/561
347+
# pylint: disable=abstract-method
348+
class EmulatorAdminCredentials(google.auth.credentials.Credentials):
349+
""" Credentials for use with the firebase local emulator.
350+
351+
This is used instead of user-supplied credentials or ADC. It will silently do nothing when
352+
asked to refresh credentials.
353+
"""
354+
def __init__(self):
355+
google.auth.credentials.Credentials.__init__(self)
356+
self.token = 'owner'
357+
358+
def refresh(self, request):
359+
pass

firebase_admin/db.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import threading
2828
from urllib import parse
2929

30-
import google.auth
3130
import requests
3231

3332
import firebase_admin
@@ -808,7 +807,7 @@ def get_client(self, db_url=None):
808807

809808
emulator_config = self._get_emulator_config(parsed_url)
810809
if emulator_config:
811-
credential = _EmulatorAdminCredentials()
810+
credential = _utils.EmulatorAdminCredentials()
812811
base_url = emulator_config.base_url
813812
params = {'ns': emulator_config.namespace}
814813
else:
@@ -965,14 +964,3 @@ def _extract_error_message(cls, response):
965964
message = 'Unexpected response from database: {0}'.format(response.content.decode())
966965

967966
return message
968-
969-
# Temporarily disable the lint rule. For more information see:
970-
# https://github.com/googleapis/google-auth-library-python/pull/561
971-
# pylint: disable=abstract-method
972-
class _EmulatorAdminCredentials(google.auth.credentials.Credentials):
973-
def __init__(self):
974-
google.auth.credentials.Credentials.__init__(self)
975-
self.token = 'owner'
976-
977-
def refresh(self, request):
978-
pass

0 commit comments

Comments
 (0)