Skip to content

Conversation

@hco
Copy link
Owner

@hco hco commented Nov 20, 2025

solves #5

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds OAuth token refresh capability to the Linear integration, enabling automatic renewal of expired OAuth tokens without user intervention.

Key Changes:

  • New OAuth token refresh module with functions to check token validity and refresh expired tokens
  • Enhanced API client to detect authentication errors and retry requests with refreshed tokens
  • Integration of token refresh callback into the main setup flow for OAuth-authenticated entries

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
custom_components/integration_linear/oauth.py New module providing async functions for token validation and refresh using Home Assistant's OAuth2 implementation
custom_components/integration_linear/api.py Updated API client to accept optional token refresh callback and handle auth errors with automatic retry after token refresh
custom_components/integration_linear/__init__.py Modified setup to create and pass token refresh callback to API client for OAuth entries

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +47 to +50
expires_at = token.get("expires_at", 0)
if expires_at and time.time() >= (expires_at - 60):
# Token is expired or about to expire, refresh it
LOGGER.debug("Token expired or about to expire, refreshing")
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition if expires_at and time.time() >= (expires_at - 60) treats expires_at = 0 as falsy, which means if a token has no expiration or expires_at is missing/0, the function returns access_token without attempting refresh even if it's invalid. Consider handling the case where expires_at is 0 explicitly, or checking if the token has required fields before returning.

Suggested change
expires_at = token.get("expires_at", 0)
if expires_at and time.time() >= (expires_at - 60):
# Token is expired or about to expire, refresh it
LOGGER.debug("Token expired or about to expire, refreshing")
expires_at = token.get("expires_at")
# If expires_at is missing or 0, treat token as expired and refresh
if not expires_at or time.time() >= (expires_at - 60):
LOGGER.debug("Token expired, missing expiration, or about to expire, refreshing")

Copilot uses AI. Check for mistakes.
break

# Try to refresh token if we have a callback and this is an auth error
if is_auth_error and retry_on_auth_error and self._token_refresh_callback and not self._refresh_in_progress:
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _refresh_in_progress flag provides basic protection against concurrent refresh attempts but is not thread-safe. If multiple async tasks call _api_wrapper simultaneously with auth errors, they could all pass the check before any sets the flag to True, leading to multiple concurrent refresh attempts. Consider using asyncio.Lock instead of a boolean flag.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

self._api_token = new_token
# Create new headers dict with updated token
retry_headers = dict(headers) if headers else {}
retry_headers["Authorization"] = new_token
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _api_token is updated on line 602, but the retry request uses retry_headers['Authorization'] = new_token. However, subsequent requests will use self._api_token which should already be updated. Ensure consistency - if the token format requires a prefix like 'Bearer', it should be applied here as well. Verify that new_token has the same format as the original self._api_token.

Suggested change
retry_headers["Authorization"] = new_token
retry_headers["Authorization"] = f"Bearer {new_token}"

Copilot uses AI. Check for mistakes.
url: str,
data: dict | None = None,
headers: dict | None = None,
retry_on_auth_error: bool = False,
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry_on_auth_error parameter defaults to False and is only set to True for _graphql_query calls. This means that any direct calls to _api_wrapper won't benefit from automatic token refresh. Consider whether this default should be True for OAuth-enabled clients, or document why GraphQL is the only method that needs this feature.

Suggested change
retry_on_auth_error: bool = False,
retry_on_auth_error: bool = True,

Copilot uses AI. Check for mistakes.
Comment on lines +174 to +179

# Create token refresh callback for OAuth
async def refresh_token() -> str:
"""Refresh OAuth token and return new access token."""
return await async_get_valid_token(hass, entry)

Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh_token callback calls async_get_valid_token, which internally calls async_refresh_token when the token is expired. However, async_get_valid_token also checks expiration and may return the existing token if not expired. When called from _api_wrapper due to an auth error, the token might not be marked as expired yet (within 60 seconds), so it could return the same invalid token. Consider calling async_refresh_token directly in the callback or ensuring the auth error forces a refresh regardless of expiration time.

Copilot uses AI. Check for mistakes.
Comment on lines +616 to +618
except Exception as refresh_exception:
LOGGER.error("Token refresh failed: %s", refresh_exception)
_raise_authentication_error()
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error logged on line 617 includes the exception details, but _raise_authentication_error() on line 618 likely raises a generic authentication error without preserving the original exception context. Consider passing the refresh_exception as the cause when raising the authentication error to maintain the full error chain for debugging.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI commented Nov 20, 2025

@hco I've opened a new pull request, #8, to work on those changes. Once the pull request is ready, I'll request review from you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants