Skip to content

Commit ca14078

Browse files
committed
Adding 3-legged auth for users getting started.
Addresses final part of #335.
1 parent dcb73d3 commit ca14078

File tree

4 files changed

+347
-7
lines changed

4 files changed

+347
-7
lines changed

gcloud/credentials.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@
1414

1515
"""A simple wrapper around the OAuth2 credentials library."""
1616

17+
import argparse
18+
import json
19+
import os
20+
import sys
21+
import tempfile
22+
import six
23+
1724
from oauth2client import client
25+
from oauth2client import file
26+
from oauth2client import tools
1827

1928

2029
def get_credentials():
@@ -59,6 +68,108 @@ def get_credentials():
5968
return client.GoogleCredentials.get_application_default()
6069

6170

71+
def _store_user_credential(credential):
72+
"""Stores a user credential as a well-known file.
73+
74+
Prompts user first if they want to store the minted token and
75+
then prompts the user for a filename to store the token
76+
information in the format needed for get_credentials().
77+
78+
:type credential: :class:`oauth2client.client.OAuth2Credentials`
79+
:param credential: A user credential to be stored.
80+
"""
81+
ans = six.moves.input('Would you like to store your tokens '
82+
'for future use? [y/n] ')
83+
if ans.strip().lower() != 'y':
84+
return
85+
86+
filename = six.moves.input('Please name the file where you wish '
87+
'to store them: ').strip()
88+
89+
payload = {
90+
'client_id': credential.client_id,
91+
'client_secret': credential.client_secret,
92+
'refresh_token': credential.refresh_token,
93+
'type': 'authorized_user',
94+
}
95+
with open(filename, 'w') as file_obj:
96+
json.dump(payload, file_obj, indent=2, sort_keys=True,
97+
separators=(',', ': '))
98+
file_obj.write('\n')
99+
100+
print 'Saved %s' % (filename,)
101+
print 'If you would like to use these credentials in the future'
102+
print 'without having to initiate the authentication flow in your'
103+
print 'browser, please set the GOOGLE_APPLICATION_CREDENTIALS'
104+
print 'environment variable:'
105+
print ' export GOOGLE_APPLICATION_CREDENTIALS=%r' % (filename,)
106+
print 'Once you\'ve done this, you can use the get_credentials()'
107+
print 'function, which relies on that environment variable.'
108+
print ''
109+
print 'Keep in mind, the refresh token can only be used with the'
110+
print 'scopes you granted in the original authorization.'
111+
112+
113+
def get_credentials_from_user_flow(scope, client_secrets_file=None):
114+
"""Gets credentials by taking user through 3-legged auth flow.
115+
116+
The necessary information to perform the flow will be stored in a client
117+
secrets file. This can be downloaded from the Google Cloud Console. First,
118+
visit "APIs & auth > Credentials", and creating a new client ID for an
119+
"Installed application" (or use an existing "Client ID for native
120+
application"). Then click "Download JSON" on your chosen "Client ID for
121+
native application" and save the client secrets file.
122+
123+
You can either pass this filename in directly via 'client_secrets_file'
124+
or set the environment variable GCLOUD_CLIENT_SECRETS.
125+
126+
For more information, see:
127+
developers.google.com/api-client-library/python/guide/aaa_client_secrets
128+
129+
:type scope: string or tuple of string
130+
:param scope: The scope against which to authenticate. (Different services
131+
require different scopes, check the documentation for which
132+
scope is required for the different levels of access to any
133+
particular API.)
134+
135+
:type client_secrets_file: string
136+
:param client_secrets_file: Optional. File containing client secrets JSON.
137+
138+
:rtype: :class:`oauth2client.client.OAuth2Credentials`
139+
:returns: A new credentials instance.
140+
:raises: ``EnvironmentError`` if stdout is not a TTY or
141+
``ValueError`` if the client secrets file is not for an installed
142+
application
143+
"""
144+
if not sys.stdout.isatty():
145+
raise EnvironmentError('Cannot initiate user flow unless user can '
146+
'interact with standard out.')
147+
148+
if client_secrets_file is None:
149+
client_secrets_file = os.getenv('GCLOUD_CLIENT_SECRETS')
150+
151+
client_type, client_info = client.clientsecrets.loadfile(
152+
client_secrets_file)
153+
if client_type != client.clientsecrets.TYPE_INSTALLED:
154+
raise ValueError('Client secrets file must be for '
155+
'installed application.')
156+
157+
redirect_uri = client_info['redirect_uris'][0]
158+
flow = client.flow_from_clientsecrets(client_secrets_file, scope,
159+
redirect_uri=redirect_uri)
160+
161+
parser = argparse.ArgumentParser(parents=[tools.argparser])
162+
flags = parser.parse_args()
163+
storage = file.Storage(tempfile.mktemp())
164+
credential = tools.run_flow(flow, storage, flags)
165+
# Remove the tempfile as a store for the credentials to prevent
166+
# future writes to a non-existent file.
167+
credential.store = None
168+
# Determine if the user would like to store these credentials.
169+
_store_user_credential(credential)
170+
return credential
171+
172+
62173
def get_for_service_account_p12(client_email, private_key_path, scope=None):
63174
"""Gets the credentials for a service account.
64175

gcloud/test_credentials.py

Lines changed: 218 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
import unittest2
1616

1717

18-
class TestCredentials(unittest2.TestCase):
18+
class Test_get_for_service_account_p12(unittest2.TestCase):
1919

20-
def test_get_for_service_account_p12_wo_scope(self):
20+
def _callFUT(self, client_email, private_key_path, scope=None):
21+
from gcloud.credentials import get_for_service_account_p12
22+
return get_for_service_account_p12(client_email, private_key_path,
23+
scope=scope)
24+
25+
def test_wo_scope(self):
2126
from tempfile import NamedTemporaryFile
2227
from gcloud import credentials
2328
from gcloud._testing import _Monkey
@@ -28,8 +33,7 @@ def test_get_for_service_account_p12_wo_scope(self):
2833
with NamedTemporaryFile() as file_obj:
2934
file_obj.write(PRIVATE_KEY)
3035
file_obj.flush()
31-
found = credentials.get_for_service_account_p12(
32-
CLIENT_EMAIL, file_obj.name)
36+
found = self._callFUT(CLIENT_EMAIL, file_obj.name)
3337
self.assertTrue(found is client._signed)
3438
expected_called_with = {
3539
'service_account_name': CLIENT_EMAIL,
@@ -38,7 +42,7 @@ def test_get_for_service_account_p12_wo_scope(self):
3842
}
3943
self.assertEqual(client._called_with, expected_called_with)
4044

41-
def test_get_for_service_account_p12_w_scope(self):
45+
def test_w_scope(self):
4246
from tempfile import NamedTemporaryFile
4347
from gcloud import credentials
4448
from gcloud._testing import _Monkey
@@ -50,8 +54,8 @@ def test_get_for_service_account_p12_w_scope(self):
5054
with NamedTemporaryFile() as file_obj:
5155
file_obj.write(PRIVATE_KEY)
5256
file_obj.flush()
53-
found = credentials.get_for_service_account_p12(
54-
CLIENT_EMAIL, file_obj.name, SCOPE)
57+
found = self._callFUT(CLIENT_EMAIL, file_obj.name,
58+
scope=SCOPE)
5559
self.assertTrue(found is client._signed)
5660
expected_called_with = {
5761
'service_account_name': CLIENT_EMAIL,
@@ -61,6 +65,192 @@ def test_get_for_service_account_p12_w_scope(self):
6165
self.assertEqual(client._called_with, expected_called_with)
6266

6367

68+
class Test__store_user_credential(unittest2.TestCase):
69+
70+
def _callFUT(self, credential):
71+
from gcloud.credentials import _store_user_credential
72+
return _store_user_credential(credential)
73+
74+
def test_user_input_no(self):
75+
import six.moves
76+
from gcloud._testing import _Monkey
77+
78+
_called_messages = []
79+
80+
def fake_input(message):
81+
_called_messages.append(message)
82+
# 'y' or 'Y' are the only acceptable values.
83+
return 'neither yes nor no'
84+
85+
with _Monkey(six.moves, input=fake_input):
86+
self._callFUT(None)
87+
88+
self.assertEqual(
89+
_called_messages,
90+
['Would you like to store your tokens for future use? [y/n] '])
91+
92+
def test_user_input_yes(self):
93+
import json
94+
import six.moves
95+
import tempfile
96+
97+
from gcloud._testing import _Monkey
98+
from oauth2client.client import OAuth2Credentials
99+
100+
_called_messages = []
101+
# In reverse order so we can use .pop().
102+
TEMPFILE = tempfile.mktemp()
103+
responses = [TEMPFILE, 'y']
104+
105+
def fake_input(message):
106+
_called_messages.append(message)
107+
return responses.pop()
108+
109+
CLIENT_ID = 'FOO'
110+
CLIENT_SECRET = 'BAR'
111+
REFRESH_TOKEN = 'BAZ'
112+
CREDENTIALS = OAuth2Credentials(None, CLIENT_ID, CLIENT_SECRET,
113+
REFRESH_TOKEN, None, None, None)
114+
with _Monkey(six.moves, input=fake_input):
115+
self._callFUT(CREDENTIALS)
116+
117+
self.assertEqual(
118+
_called_messages,
119+
['Would you like to store your tokens for future use? [y/n] ',
120+
'Please name the file where you wish to store them: '])
121+
122+
with open(TEMPFILE, 'r') as file_obj:
123+
STORED_CREDS = json.load(file_obj)
124+
125+
expected_creds = {
126+
'client_id': CLIENT_ID,
127+
'client_secret': CLIENT_SECRET,
128+
'refresh_token': REFRESH_TOKEN,
129+
'type': 'authorized_user',
130+
}
131+
self.assertEqual(STORED_CREDS, expected_creds)
132+
133+
134+
class Test_get_credentials_from_user_flow(unittest2.TestCase):
135+
136+
def _callFUT(self, scope, client_secrets_file=None):
137+
from gcloud.credentials import get_credentials_from_user_flow
138+
return get_credentials_from_user_flow(
139+
scope, client_secrets_file=client_secrets_file)
140+
141+
def test_no_tty(self):
142+
import sys
143+
from gcloud._testing import _Monkey
144+
145+
STDOUT = _MockStdout(isatty=False)
146+
with _Monkey(sys, stdout=STDOUT):
147+
with self.assertRaises(EnvironmentError):
148+
self._callFUT(None)
149+
150+
def test_filename_from_environ(self):
151+
import os
152+
import sys
153+
154+
from gcloud._testing import _Monkey
155+
from oauth2client import client
156+
157+
STDOUT = _MockStdout(isatty=True)
158+
FILENAME = 'FOO'
159+
GCLOUD_KEY = 'GCLOUD_CLIENT_SECRETS'
160+
FAKE_ENVIRON = {GCLOUD_KEY: FILENAME}
161+
162+
_called_keys = []
163+
164+
def fake_getenv(key):
165+
_called_keys.append(key)
166+
return FAKE_ENVIRON.get(key)
167+
168+
_called_filenames = []
169+
170+
def fake_loadfile(filename):
171+
_called_filenames.append(filename)
172+
return 'NOT_INSTALLED_TYPE', None
173+
174+
with _Monkey(sys, stdout=STDOUT):
175+
with _Monkey(os, getenv=fake_getenv):
176+
with _Monkey(client.clientsecrets, loadfile=fake_loadfile):
177+
with self.assertRaises(ValueError):
178+
self._callFUT(None)
179+
180+
self.assertEqual(_called_keys, [GCLOUD_KEY])
181+
self.assertEqual(_called_filenames, [FILENAME])
182+
183+
def test_succeeds(self):
184+
import argparse
185+
import sys
186+
187+
from gcloud._testing import _Monkey
188+
from gcloud import credentials
189+
from oauth2client import client
190+
from oauth2client.file import Storage
191+
from oauth2client import tools
192+
193+
STDOUT = _MockStdout(isatty=True)
194+
SCOPE = 'SCOPE'
195+
FILENAME = 'FILENAME'
196+
REDIRECT_URI = 'REDIRECT_URI'
197+
MOCK_CLIENT_INFO = {'redirect_uris': [REDIRECT_URI]}
198+
FLOW = object()
199+
CLIENT_ID = 'FOO'
200+
CLIENT_SECRET = 'BAR'
201+
REFRESH_TOKEN = 'BAZ'
202+
CREDENTIALS = client.OAuth2Credentials(None, CLIENT_ID, CLIENT_SECRET,
203+
REFRESH_TOKEN, None, None, None)
204+
205+
_called_loadfile = []
206+
207+
def fake_loadfile(*args, **kwargs):
208+
_called_loadfile.append((args, kwargs))
209+
return client.clientsecrets.TYPE_INSTALLED, MOCK_CLIENT_INFO
210+
211+
_called_flow_from_clientsecrets = []
212+
213+
def mock_flow(client_secrets_file, scope, redirect_uri=None):
214+
_called_flow_from_clientsecrets.append(
215+
(client_secrets_file, scope, redirect_uri))
216+
return FLOW
217+
218+
_called_run_flow = []
219+
220+
def mock_run_flow(flow, storage, flags):
221+
_called_run_flow.append((flow, storage, flags))
222+
return CREDENTIALS
223+
224+
_called_store_user_credential = []
225+
226+
def store_cred(credential):
227+
_called_store_user_credential.append(credential)
228+
229+
with _Monkey(sys, stdout=STDOUT):
230+
with _Monkey(client.clientsecrets, loadfile=fake_loadfile):
231+
with _Monkey(client, flow_from_clientsecrets=mock_flow):
232+
with _Monkey(tools, run_flow=mock_run_flow):
233+
with _Monkey(credentials,
234+
_store_user_credential=store_cred):
235+
with _Monkey(argparse,
236+
ArgumentParser=_MockArgumentParser):
237+
self._callFUT(SCOPE,
238+
client_secrets_file=FILENAME)
239+
240+
self.assertEqual(_called_loadfile, [((FILENAME,), {})])
241+
self.assertEqual(_called_flow_from_clientsecrets,
242+
[(FILENAME, SCOPE, REDIRECT_URI)])
243+
244+
# Unpack expects a single output
245+
run_flow_input, = _called_run_flow
246+
self.assertEqual(len(run_flow_input), 3)
247+
self.assertEqual(run_flow_input[0], FLOW)
248+
self.assertTrue(isinstance(run_flow_input[1], Storage))
249+
self.assertTrue(run_flow_input[2] is _MockArgumentParser._MARKER)
250+
251+
self.assertEqual(_called_store_user_credential, [CREDENTIALS])
252+
253+
64254
class _Credentials(object):
65255

66256
service_account_name = 'testing@example.com'
@@ -85,3 +275,24 @@ def get_application_default():
85275
def SignedJwtAssertionCredentials(self, **kw):
86276
self._called_with = kw
87277
return self._signed
278+
279+
280+
class _MockStdout(object):
281+
282+
def __init__(self, isatty=True):
283+
self._isatty = isatty
284+
285+
def isatty(self):
286+
return self._isatty
287+
288+
289+
class _MockArgumentParser(object):
290+
291+
_MARKER = object()
292+
293+
def __init__(self, *args, **kwargs):
294+
self._args = args
295+
self._kwargs = kwargs
296+
297+
def parse_args(self):
298+
return self._MARKER

0 commit comments

Comments
 (0)