Closed ajram23 closed 1 month ago
@parthea any thoughts on this issue? I have even tried the offline_access mode but it doesnt work "Access blocked: Contact Assistant’s request is invalid". Any help is greatly appreciated!
A few clarifying questions:
Thanks for getting back @clundin25
Appreciate the questions and the help 🙏🏼
Please note, there is one interesting caveat - my Mac app is being deployed with its own docker like container where this python script/service runs. So it accesses it through localhost:5000 not sure if this is an issue.
I think this is only relevant to the initial 3-legged exchange. Once you have the refresh token, you should be able to use it to exchange for a new access token.
Can you share a snippet to help me understand how the Python code looks?
@clundin25 Here you go def get_service(max_retries=3, backoff_factor=0.5): """Generate a Google API service object using provided tokens.""" app_logger.info("Attempting to get Google API service")
for attempt in range(max_retries):
app_logger.info(f"Attempt {attempt + 1} of {max_retries}")
credentials = load_tokens() # Load credentials instead of token_info
if not credentials:
app_logger.critical("No valid credentials loaded")
rollbar.report_exc_info()
raise ValueError("No valid credentials available for authenticated user")
try:
app_logger.info(f"Credentials loaded. Valid: {credentials.valid}, Expired: {credentials.expired}")
if credentials.expired or not credentials.valid:
app_logger.info("Credentials are expired or invalid, attempting to refresh")
if not verify_and_refresh_token(credentials):
app_logger.warning("Failed to refresh token")
raise ValueError("Failed to refresh token")
app_logger.info("Token successfully refreshed")
save_tokens(credentials) # Save the refreshed tokens
service = build('people', 'v1', credentials=credentials)
app_logger.info("Service successfully created")
return service
except (HttpError, requests.Timeout) as e:
app_logger.error(f"Transient network error: {e}")
rollbar.report_exc_info()
if attempt < max_retries - 1:
sleep_time = backoff_factor * (2 ** attempt)
app_logger.info(f"Retrying in {sleep_time} seconds.")
time.sleep(sleep_time)
else:
app_logger.error("Failed to connect to Google API after multiple attempts.")
raise
except Exception as e:
app_logger.error(f"Error during service creation: {str(e)}")
rollbar.report_exc_info()
raise
app_logger.critical("All attempts to create service failed") (Should I even be using the offline_ scope or serverAuthCode?
Can you share the functions:
verify_and_refresh_token
load_tokens
Here you go:
def verify_and_refresh_token(creds):
if not creds.valid:
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
save_tokens(creds)
return True
except RefreshError as e:
app_logger.error(f"Failed to refresh token: {str(e)}")
return False
else:
return False
return True
def load_tokens():
"""Load tokens from a JSON file."""
if os.path.exists(Config.TOKEN_FILE):
with open(Config.TOKEN_FILE, 'r', encoding='utf-8') as file:
token_info = json.load(file)
if token_info.get('refresh_token'):
app_logger.debug(f"Loaded tokens: {token_info.keys()}")
app_logger.debug(f"Client ID: {token_info.get('client_id', 'Not found')[:10]}...")
app_logger.debug(f"Token expiry: {token_info.get('expiry', 'Not found')}")
return Credentials(
token=token_info['token'],
refresh_token=token_info['refresh_token'],
token_uri=token_info['token_uri'],
client_id=token_info['client_id'],
client_secret=token_info['client_secret'],
scopes=token_info['scopes'],
expiry=datetime.fromisoformat(token_info['expiry']) if token_info.get('expiry') else None
)
else:
app_logger.error(f"Token file not found: {Config.TOKEN_FILE}")
return None
Also one more question - should I be configuring my Authorized redirect URIs and does it support localhost/127.0.0.1?
@clundin25 please let me know if you need anything else.
- Does the python application attempt to refresh the credentials with a different oauth2.0 client_id than what they were created with?
Can you confirm that the same client_id and secret that the refresh token was created with are used to refresh the access token?
@clundin25 Apologies missed this notification.
No, I am using one token created for the iOS/Mac (with bundle ID) and the other created from the server(Web with authorized redirect to http://127.0.0.1:5002/oauth2callback). Do I have it wrong?
I think the issue may be that the refresh operation possess a combination of client_secret
and client_id
that were not used to create the refresh_token
.
Can you double check that the config from load_tokens
has the correct client_secret
and client_id
?
@clundin25 Can we please take a step back and make sure i have the right flow. Here is my understanding and set up.
Setup:
Flow:
If the flow is wrong could you please let me know what's the right approach given my setup? Like I said I feel like I am missing something basic.
Perhaps here is the missing code you need: @app.route('/contacts_api_token', methods=['GET']) def contacts_api(): access_token = request.headers.get('Authorization', '').split(' ')[1] refresh_token = request.headers.get('Refresh-Token') granted_scopes = request.headers.get('Granted-Scopes')
if not access_token:
return jsonify({'success': False, 'error': 'No access token provided'}), 401
try:
# Load client ID and secret from credentials file
try:
with open(Config.CREDENTIALS_FILE, 'r', encoding='utf-8') as cred_file:
cred_data = json.load(cred_file)
client_id = cred_data['installed']['client_id']
client_secret = cred_data['installed']['client_secret']
app_logger.debug(f"Loaded client ID: {client_id[:10]}...")
except FileNotFoundError:
app_logger.error(f"Credentials file not found: {Config.CREDENTIALS_FILE}")
return jsonify({'success': False, 'error': 'Credentials file not found'}), 500
except json.JSONDecodeError as json_err:
app_logger.error(f"JSON decode error in credentials file: {str(json_err)}")
return jsonify({'success': False, 'error': 'Invalid JSON in credentials file'}), 500
except KeyError as key_err:
app_logger.error(f"Key error in credentials file: {str(key_err)}")
return jsonify({'success': False, 'error': 'Missing key in credentials file'}), 500
except Exception as e:
app_logger.error(f"Failed to load credentials file: {str(e)}")
return jsonify({'success': False, 'error': 'Failed to load OAuth credentials'}), 500
# Update tokens in storage
token_info = {
'token': access_token,
'refresh_token': refresh_token,
'token_uri': 'https://oauth2.googleapis.com/token',
'client_id': client_id,
'client_secret': client_secret,
'scopes': granted_scopes.split(',') # Splits the string by commas and converts it into an array of scopes
}
credentials = Credentials(**token_info)
if not verify_and_refresh_token(credentials):
return jsonify({'success': False, 'error': 'Token verification or refresh failed'}), 401
save_tokens(credentials)
# Check if the required scope is present
required_scope = 'https://www.googleapis.com/auth/contacts'
if required_scope not in credentials.scopes:
app_logger.error(f"Token does not have the required scope. Scopes: {credentials.scopes}")
return jsonify({'success': False, 'error': 'Insufficient scope'}), 403
service = get_service()
if not service:
return jsonify({'success': False, 'error': 'Service initialization failed'}), 500
# Fetch only one contact
results = service.people().connections().list(
resourceName='people/me',
pageSize=1,
personFields='names,emailAddresses'
).execute()
# Results provides #eTag, nextPageToken, totalItems, totalPeople
# save TOTAL_CONTACTS to env
total_contacts = results['totalItems']
Config.set_dotenv_key_value('TOTAL_CONTACTS', str(total_contacts))
Config.TOTAL_CONTACTS = str(total_contacts)
return jsonify({'success': True, 'results': total_contacts})
except Exception as e:
rollbar.report_exc_info()
app_logger.error(f"[❌] Error in contacts_api: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@clundin25 Happy friday! friendly bump!
You shouldn't be mixing the refresh token between the two projects. It is not portable across oauth2 projects.
The refresh token can produce a new access token without the full authorization flow https://datatracker.ietf.org/doc/html/rfc6749#autoid-10.
I don't think you need to do the authorization flow in the server but simply request a new access token using the existing refresh token from the Mac App.
Why do you have two oauth2 projects?
@clundin25 There is no authorization flow in the server, just a request for refresh tokens. Given that the server ends up syncing data or updating data as mention before. The Mac app project only allows for a .plist file to be downloaded. I am clearly missing something.
@clundin25 perhaps its easier to state what I want to do 1. I want the server (running locally on a container) to be able sync on be sync on behalf of the user (user could be asleep and computer is syncing while open) 2. When the user requests an update to a contact perform it.
@clundin25 Curious, would it be easier to do this on email or a quick call?
Hey @ajram23 unfortunately I think this has left the scope of this repository.
Can you reach out to Google support for further assistance? https://cloud.google.com/support/
@clundin25 oh well. All I am asking for is the recommended way to use your APIs in my scenario. It's as simple as that. Thanks for engaging!
@clundin25 moreover the documentation is outdated https://developers.google.com/identity/sign-in/ios/offline-access referring to libraries that have been deprecated for years. I can't seem to find a replacement in the new library either for server auth.
I have a Mac app with a simple container that runs sync APIs with Google Contacts. The user logs into Google to authorize the app, and everything works as expected. The access and refresh tokens are passed to a server running in the container. However, after an hour, when the token refresh occurs, I get an "unauthorized_client" issue.
I've set up two OAuth client IDs: one for iOS/Mac and another for the container (Mac). When the token expires, I try to refresh it using the client ID and secret from the Mac OAuth client, but I receive the following error:
The Mac app sends the refresh token to the container every 55 minutes.
It feels like I'm making a rudimentary mistake. Any suggestions would be greatly appreciated. Below is the code for token handling and sending them to the server:
The sync code is in the container because it processes the data using Python, which is not something I want to do in Swift. Essentially, a local Docker runs Python scripts to process the contacts. My client is a Mac tray app that signs into Google and sends the tokens to this container to do the heavy lifting.
Happy to answer more specific questions.