Skip to content

Commit e4950a0

Browse files
authored
Support for creating custom tokens without service account credentials (#175)
* Ability to sign custom tokens without a service account * Fixing tests for py3 * Fix for Python 3.5 * Implemented go/firebase-admin-sign * Sending the required headers when calling the metadata service * Renamed service_account option to service_account_+id * Added discovery failiure test * Added snippet; Updated error message to be in sync with docs * Renamed service_account_id option to serviceAccountId for consistency
1 parent 14e5dc4 commit e4950a0

File tree

8 files changed

+227
-21
lines changed

8 files changed

+227
-21
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
- [added] Implemented the ability to create custom tokens without
4+
service account credentials.
35
- [added] Admin SDK can now read the project ID from both `GCLOUD_PROJECT` and
46
`GOOGLE_CLOUD_PROJECT` environment variables.
57

firebase_admin/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME):
5050
credential: A credential object used to initialize the SDK (optional). If none is provided,
5151
Google Application Default Credentials are used.
5252
options: A dictionary of configuration options (optional). Supported options include
53-
``databaseURL``, ``storageBucket``, ``projectId``, ``databaseAuthVariableOverride``
54-
and ``httpTimeout``. If ``httpTimeout`` is not set, HTTP connections initiated by client
55-
modules such as ``db`` will not time out.
53+
``databaseURL``, ``storageBucket``, ``projectId``, ``databaseAuthVariableOverride``,
54+
``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, HTTP
55+
connections initiated by client modules such as ``db`` will not time out.
5656
name: Name of the app (optional).
5757
Returns:
5858
App: A newly initialized instance of App.

firebase_admin/_token_gen.py

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
import cachecontrol
2121
import requests
2222
import six
23+
from google.auth import credentials
24+
from google.auth import exceptions
25+
from google.auth import iam
2326
from google.auth import jwt
2427
from google.auth import transport
2528
import google.oauth2.id_token
26-
27-
from firebase_admin import credentials
29+
import google.oauth2.service_account
2830

2931

3032
# ID token constants
@@ -46,9 +48,12 @@
4648
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash',
4749
'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub'
4850
])
51+
METADATA_SERVICE_URL = ('http://metadata/computeMetadata/v1/instance/service-accounts/'
52+
'default/email')
4953

5054
# Error codes
5155
COOKIE_CREATE_ERROR = 'COOKIE_CREATE_ERROR'
56+
TOKEN_SIGN_ERROR = 'TOKEN_SIGN_ERROR'
5257

5358

5459
class ApiCallError(Exception):
@@ -60,20 +65,81 @@ def __init__(self, code, message, error=None):
6065
self.detail = error
6166

6267

68+
class _SigningProvider(object):
69+
"""Stores a reference to a google.auth.crypto.Signer."""
70+
71+
def __init__(self, signer, signer_email):
72+
self._signer = signer
73+
self._signer_email = signer_email
74+
75+
@property
76+
def signer(self):
77+
return self._signer
78+
79+
@property
80+
def signer_email(self):
81+
return self._signer_email
82+
83+
@classmethod
84+
def from_credential(cls, google_cred):
85+
return _SigningProvider(google_cred.signer, google_cred.signer_email)
86+
87+
@classmethod
88+
def from_iam(cls, request, google_cred, service_account):
89+
signer = iam.Signer(request, google_cred, service_account)
90+
return _SigningProvider(signer, service_account)
91+
92+
6393
class TokenGenerator(object):
6494
"""Generates custom tokens and session cookies."""
6595

6696
def __init__(self, app, client):
67-
self._app = app
68-
self._client = client
97+
self.app = app
98+
self.client = client
99+
self.request = transport.requests.Request()
100+
self._signing_provider = None
101+
102+
def _init_signing_provider(self):
103+
"""Initializes a signing provider by following the go/firebase-admin-sign protocol."""
104+
# If the SDK was initialized with a service account, use it to sign bytes.
105+
google_cred = self.app.credential.get_credential()
106+
if isinstance(google_cred, google.oauth2.service_account.Credentials):
107+
return _SigningProvider.from_credential(google_cred)
108+
109+
# If the SDK was initialized with a service account email, use it with the IAM service
110+
# to sign bytes.
111+
service_account = self.app.options.get('serviceAccountId')
112+
if service_account:
113+
return _SigningProvider.from_iam(self.request, google_cred, service_account)
114+
115+
# If the SDK was initialized with some other credential type that supports signing
116+
# (e.g. GAE credentials), use it to sign bytes.
117+
if isinstance(google_cred, credentials.Signing):
118+
return _SigningProvider.from_credential(google_cred)
119+
120+
# Attempt to discover a service account email from the local Metadata service. Use it
121+
# with the IAM service to sign bytes.
122+
resp = self.request(url=METADATA_SERVICE_URL, headers={'Metadata-Flavor': 'Google'})
123+
service_account = resp.data.decode()
124+
return _SigningProvider.from_iam(self.request, google_cred, service_account)
125+
126+
@property
127+
def signing_provider(self):
128+
"""Initializes and returns the SigningProvider instance to be used."""
129+
if not self._signing_provider:
130+
try:
131+
self._signing_provider = self._init_signing_provider()
132+
except Exception as error:
133+
url = 'https://firebase.google.com/docs/auth/admin/create-custom-tokens'
134+
raise ValueError(
135+
'Failed to determine service account: {0}. Make sure to initialize the SDK '
136+
'with service account credentials or specify a service account ID with '
137+
'iam.serviceAccounts.signBlob permission. Please refer to {1} for more '
138+
'details on creating custom tokens.'.format(error, url))
139+
return self._signing_provider
69140

70141
def create_custom_token(self, uid, developer_claims=None):
71142
"""Builds and signs a Firebase custom auth token."""
72-
if not isinstance(self._app.credential, credentials.Certificate):
73-
raise ValueError(
74-
'Must initialize Firebase App with a certificate credential '
75-
'to call create_custom_token().')
76-
77143
if developer_claims is not None:
78144
if not isinstance(developer_claims, dict):
79145
raise ValueError('developer_claims must be a dictionary')
@@ -93,10 +159,11 @@ def create_custom_token(self, uid, developer_claims=None):
93159
if not uid or not isinstance(uid, six.string_types) or len(uid) > 128:
94160
raise ValueError('uid must be a string between 1 and 128 characters.')
95161

162+
signing_provider = self.signing_provider
96163
now = int(time.time())
97164
payload = {
98-
'iss': self._app.credential.service_account_email,
99-
'sub': self._app.credential.service_account_email,
165+
'iss': signing_provider.signer_email,
166+
'sub': signing_provider.signer_email,
100167
'aud': FIREBASE_AUDIENCE,
101168
'uid': uid,
102169
'iat': now,
@@ -105,7 +172,12 @@ def create_custom_token(self, uid, developer_claims=None):
105172

106173
if developer_claims is not None:
107174
payload['claims'] = developer_claims
108-
return jwt.encode(self._app.credential.signer, payload)
175+
try:
176+
return jwt.encode(signing_provider.signer, payload)
177+
except exceptions.TransportError as error:
178+
msg = 'Failed to sign custom token. {0}'.format(error)
179+
raise ApiCallError(TOKEN_SIGN_ERROR, msg, error)
180+
109181

110182
def create_session_cookie(self, id_token, expires_in):
111183
"""Creates a session cookie from the provided ID token."""
@@ -131,7 +203,7 @@ def create_session_cookie(self, id_token, expires_in):
131203
'validDuration': expires_in,
132204
}
133205
try:
134-
response = self._client.request('post', 'createSessionCookie', json=payload)
206+
response = self.client.request('post', 'createSessionCookie', json=payload)
135207
except requests.exceptions.RequestException as error:
136208
self._handle_http_error(COOKIE_CREATE_ERROR, 'Failed to create session cookie', error)
137209
else:

firebase_admin/auth.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ def create_custom_token(uid, developer_claims=None, app=None):
109109
110110
Raises:
111111
ValueError: If input parameters are invalid.
112+
AuthError: If an error occurs while creating the token using the remote IAM service.
112113
"""
113114
token_generator = _get_auth_service(app).token_generator
114-
return token_generator.create_custom_token(uid, developer_claims)
115-
115+
try:
116+
return token_generator.create_custom_token(uid, developer_claims)
117+
except _token_gen.ApiCallError as error:
118+
raise AuthError(error.code, str(error), error.detail)
116119

117120
def verify_id_token(id_token, app=None, check_revoked=False):
118121
"""Verifies the signature and data for the provided JWT.

integration/test_auth.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
import pytest
2323
import requests
2424

25+
import firebase_admin
2526
from firebase_admin import auth
26-
27+
from firebase_admin import credentials
28+
import google.oauth2.credentials
29+
from google.auth import transport
2730

2831
_verify_token_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken'
2932
_verify_password_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword'
@@ -57,6 +60,20 @@ def test_custom_token(api_key):
5760
claims = auth.verify_id_token(id_token)
5861
assert claims['uid'] == 'user1'
5962

63+
def test_custom_token_without_service_account(api_key):
64+
google_cred = firebase_admin.get_app().credential.get_credential()
65+
cred = CredentialWrapper.from_existing_credential(google_cred)
66+
custom_app = firebase_admin.initialize_app(cred, {
67+
'serviceAccountId': google_cred.service_account_email,
68+
}, 'temp-app')
69+
try:
70+
custom_token = auth.create_custom_token('user1', app=custom_app)
71+
id_token = _sign_in(custom_token, api_key)
72+
claims = auth.verify_id_token(id_token)
73+
assert claims['uid'] == 'user1'
74+
finally:
75+
firebase_admin.delete_app(custom_app)
76+
6077
def test_custom_token_with_claims(api_key):
6178
dev_claims = {'premium' : True, 'subscription' : 'silver'}
6279
custom_token = auth.create_custom_token('user2', dev_claims)
@@ -353,3 +370,20 @@ def test_import_users_with_password(api_key):
353370
assert len(id_token) > 0
354371
finally:
355372
auth.delete_user(uid)
373+
374+
375+
class CredentialWrapper(credentials.Base):
376+
"""A custom Firebase credential that wraps an OAuth2 token."""
377+
378+
def __init__(self, token):
379+
self._delegate = google.oauth2.credentials.Credentials(token)
380+
381+
def get_credential(self):
382+
return self._delegate
383+
384+
@classmethod
385+
def from_existing_credential(cls, google_cred):
386+
if not google_cred.token:
387+
request = transport.requests.Request()
388+
google_cred.refresh(request)
389+
return CredentialWrapper(google_cred.token)

snippets/auth/index.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ def initialize_sdk_with_refresh_token():
5050
# [END initialize_sdk_with_refresh_token]
5151
firebase_admin.delete_app(default_app)
5252

53+
def initialize_sdk_with_service_account_id():
54+
# [START initialize_sdk_with_service_account_id]
55+
options = {
56+
'serviceAccountId': '[email protected]',
57+
}
58+
firebase_admin.initialize_app(options=options)
59+
# [END initialize_sdk_with_service_account_id]
60+
firebase_admin.delete_app(firebase_admin.get_app())
61+
5362
def access_services_default():
5463
cred = credentials.Certificate('path/to/service.json')
5564
# [START access_services_default]

tests/test_token_gen.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Test cases for the firebase_admin._token_gen module."""
1616

17+
import base64
1718
import datetime
1819
import json
1920
import os
@@ -109,8 +110,11 @@ def _instrument_user_manager(app, status, payload):
109110

110111
def _overwrite_cert_request(app, request):
111112
auth_service = auth._get_auth_service(app)
112-
token_verifier = auth_service.token_verifier
113-
token_verifier.request = request
113+
auth_service.token_verifier.request = request
114+
115+
def _overwrite_iam_request(app, request):
116+
auth_service = auth._get_auth_service(app)
117+
auth_service.token_generator.request = request
114118

115119
@pytest.fixture(scope='module')
116120
def auth_app():
@@ -194,6 +198,74 @@ def test_noncert_credential(self, user_mgt_app):
194198
with pytest.raises(ValueError):
195199
auth.create_custom_token(MOCK_UID, app=user_mgt_app)
196200

201+
def test_sign_with_iam(self):
202+
options = {'serviceAccountId': 'test-service-account'}
203+
app = firebase_admin.initialize_app(
204+
testutils.MockCredential(), name='iam-signer-app', options=options)
205+
try:
206+
signature = base64.b64encode(b'test').decode()
207+
iam_resp = '{{"signature": "{0}"}}'.format(signature)
208+
_overwrite_iam_request(app, testutils.MockRequest(200, iam_resp))
209+
custom_token = auth.create_custom_token(MOCK_UID, app=app).decode()
210+
assert custom_token.endswith('.' + signature)
211+
self._verify_signer(custom_token, 'test-service-account')
212+
finally:
213+
firebase_admin.delete_app(app)
214+
215+
def test_sign_with_iam_error(self):
216+
options = {'serviceAccountId': 'test-service-account'}
217+
app = firebase_admin.initialize_app(
218+
testutils.MockCredential(), name='iam-signer-app', options=options)
219+
try:
220+
iam_resp = '{"error": {"code": 403, "message": "test error"}}'
221+
_overwrite_iam_request(app, testutils.MockRequest(403, iam_resp))
222+
with pytest.raises(auth.AuthError) as excinfo:
223+
auth.create_custom_token(MOCK_UID, app=app)
224+
assert excinfo.value.code == _token_gen.TOKEN_SIGN_ERROR
225+
assert iam_resp in str(excinfo.value)
226+
finally:
227+
firebase_admin.delete_app(app)
228+
229+
def test_sign_with_discovered_service_account(self):
230+
request = testutils.MockRequest(200, 'discovered-service-account')
231+
app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app')
232+
try:
233+
_overwrite_iam_request(app, request)
234+
# Force initialization of the signing provider. This will invoke the Metadata service.
235+
auth_service = auth._get_auth_service(app)
236+
assert auth_service.token_generator.signing_provider is not None
237+
# Now invoke the IAM signer.
238+
signature = base64.b64encode(b'test').decode()
239+
request.response = testutils.MockResponse(
240+
200, '{{"signature": "{0}"}}'.format(signature))
241+
custom_token = auth.create_custom_token(MOCK_UID, app=app).decode()
242+
assert custom_token.endswith('.' + signature)
243+
self._verify_signer(custom_token, 'discovered-service-account')
244+
assert len(request.log) == 2
245+
assert request.log[0][1]['headers'] == {'Metadata-Flavor': 'Google'}
246+
finally:
247+
firebase_admin.delete_app(app)
248+
249+
def test_sign_with_discovery_failure(self):
250+
request = testutils.MockFailedRequest(Exception('test error'))
251+
app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app')
252+
try:
253+
_overwrite_iam_request(app, request)
254+
with pytest.raises(ValueError) as excinfo:
255+
auth.create_custom_token(MOCK_UID, app=app)
256+
assert str(excinfo.value).startswith('Failed to determine service account: test error')
257+
assert len(request.log) == 1
258+
assert request.log[0][1]['headers'] == {'Metadata-Flavor': 'Google'}
259+
finally:
260+
firebase_admin.delete_app(app)
261+
262+
def _verify_signer(self, token, signer):
263+
segments = token.split('.')
264+
assert len(segments) == 3
265+
body = json.loads(base64.b64decode(segments[1]).decode())
266+
assert body['iss'] == signer
267+
assert body['sub'] == signer
268+
197269

198270
class TestCreateSessionCookie(object):
199271

tests/testutils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,25 @@ class MockRequest(transport.Request):
8686

8787
def __init__(self, status, response):
8888
self.response = MockResponse(status, response)
89+
self.log = []
8990

9091
def __call__(self, *args, **kwargs):
92+
self.log.append((args, kwargs))
9193
return self.response
9294

9395

96+
class MockFailedRequest(transport.Request):
97+
"""A mock HTTP request that fails by raising an exception."""
98+
99+
def __init__(self, error):
100+
self.error = error
101+
self.log = []
102+
103+
def __call__(self, *args, **kwargs):
104+
self.log.append((args, kwargs))
105+
raise self.error
106+
107+
94108
class MockGoogleCredential(credentials.Credentials):
95109
"""A mock Google authentication credential."""
96110
def refresh(self, request):

0 commit comments

Comments
 (0)