googleapis / google-auth-library-python

Google Auth Python Library
https://googleapis.dev/python/google-auth/latest/
Apache License 2.0
774 stars 305 forks source link

Unauthorized client issue #1561

Closed ajram23 closed 1 month ago

ajram23 commented 2 months ago

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:

Failed to refresh token: ('unauthorized_client: Unauthorized', {'error': 'unauthorized_client', 'error_description': 'Unauthorized'})

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:

GIDSignIn.sharedInstance.restorePreviousSignIn { user, error in
    if let error = error {
        print("[syncData] Failed to restore previous sign-in: \(error)")
        completion(false, "Failed to restore previous sign-in")
        return
    }

    guard let user = user else {
        print("[syncData] No user signed in")
        completion(false, "No user signed in")
        return
    }

    user.refreshTokensIfNeeded { user, error in
        if let error = error {
            print("[syncData] Failed to refresh tokens: \(error)")
            completion(false, "Failed to refresh tokens")
            return
        }

        guard let user = user else {
            print("[syncData] No user after refreshing tokens")
            completion(false, "No user after refreshing tokens")
            return
        }

        Task {
            do {
                let scopesString = user.grantedScopes?.joined(separator: ",") ?? ""
                try await self.sendTokenToServer(accessToken: user.accessToken.tokenString, refreshToken: user.refreshToken.tokenString, scopesString: scopesString)
            } catch {
                print("[syncData] Error sending tokens to server: \(error)")
            }
        }
    }
}

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.

ajram23 commented 2 months 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!

clundin25 commented 1 month ago

A few clarifying questions:

  1. Can you identify the Python libraries that are involved?
  2. Once the python application possess an access token, refresh token, client_id, and client_secret, can it refresh on it's own?
  3. Does the python application attempt to refresh the credentials with a different oauth2.0 client_id than what they were created with?
ajram23 commented 1 month ago

Thanks for getting back @clundin25

  1. Mac Client - GoogleSign 7.1 / Python -> google_api_python_client==2.132.0, google_auth_oauthlib==1.2.0
  2. Yes, the python server app can refresh data on a regular basis. It also can update a contact if the client asks for an update to be made.
  3. I have tried multiple ways and landed on Oath client for Mac and the server has auth web client. I have also looked at https://developers.google.com/identity/sign-in/ios/backend-auth but I am getting Access blocked: Authorization Error
  4. 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.

Appreciate the questions and the help 🙏🏼

clundin25 commented 1 month ago

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?

ajram23 commented 1 month ago

@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?
clundin25 commented 1 month ago

Can you share the functions:

ajram23 commented 1 month ago

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?

ajram23 commented 1 month ago

@clundin25 please let me know if you need anything else.

clundin25 commented 1 month ago
  1. 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?

ajram23 commented 1 month ago

@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?

clundin25 commented 1 month ago

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?

ajram23 commented 1 month ago

@clundin25 Can we please take a step back and make sure i have the right flow. Here is my understanding and set up.

Setup:

  1. Mac app (Mac client OAuth 2 client with bundle ID)
  2. Uses container with services running localhost (2 different ports) - Web App Client ID

Flow:

  1. User signs into Google in the Mac app
  2. Sends the refresh token to server.
  3. Server uses the refresh token to download contacts
  4. Token expires in an hour
  5. Server tries to refresh using the web App client ID with a localhost call back (will a localhost call back even work?) Runs into an error.

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.

ajram23 commented 1 month ago

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
ajram23 commented 1 month ago

@clundin25 Happy friday! friendly bump!

clundin25 commented 1 month ago

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?

ajram23 commented 1 month ago

@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.

ajram23 commented 1 month ago

@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.

ajram23 commented 1 month ago

@clundin25 Curious, would it be easier to do this on email or a quick call?

clundin25 commented 1 month ago

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/

ajram23 commented 1 month ago

@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!

ajram23 commented 1 month ago

@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.