Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions custom_components/integration_linear/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .const import CONF_API_TOKEN, DOMAIN, LOGGER
from .coordinator import BlueprintDataUpdateCoordinator
from .data import IntegrationBlueprintData
from .oauth import async_get_valid_token

if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall
Expand Down Expand Up @@ -162,13 +163,21 @@ async def async_setup_entry(

# Get API token - either from OAuth or from config entry data
api_token: str
token_refresh_callback = None
if CONF_API_TOKEN in entry.data:
# API key authentication
api_token = entry.data[CONF_API_TOKEN]
else:
# OAuth authentication - token is stored in entry.data
token = entry.data.get("token", {})
api_token = token.get("access_token", "")

# 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)

Comment on lines +174 to +179
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.
token_refresh_callback = refresh_token

coordinator = BlueprintDataUpdateCoordinator(
hass=hass,
Expand All @@ -180,6 +189,7 @@ async def async_setup_entry(
client=IntegrationBlueprintApiClient(
api_token=api_token,
session=async_get_clientsession(hass),
token_refresh_callback=token_refresh_callback,
),
integration=async_get_loaded_integration(hass, entry.domain),
coordinator=coordinator,
Expand Down
47 changes: 46 additions & 1 deletion custom_components/integration_linear/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import socket
from typing import Any
from typing import Any, Callable, Awaitable

import aiohttp
import async_timeout
Expand Down Expand Up @@ -61,10 +61,13 @@ def __init__(
self,
api_token: str,
session: aiohttp.ClientSession,
token_refresh_callback: Callable[[], Awaitable[str]] | None = None,
) -> None:
"""Initialize Linear API Client."""
self._api_token = api_token
self._session = session
self._token_refresh_callback = token_refresh_callback
self._refresh_in_progress = False

async def async_validate_token(self) -> None:
"""Validate the API token by making a simple query."""
Expand Down Expand Up @@ -550,6 +553,7 @@ async def _graphql_query(self, query: str, variables: dict | None = None) -> Any
"Authorization": self._api_token,
"Content-Type": "application/json",
},
retry_on_auth_error=True,
)

async def _api_wrapper(
Expand All @@ -558,6 +562,7 @@ async def _api_wrapper(
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.
) -> Any:
"""Get information from the API."""
try:
Expand All @@ -573,6 +578,46 @@ async def _api_wrapper(
result = await response.json()
LOGGER.debug("Response: %r", result)

# Check for authentication errors
is_auth_error = False
if response.status in (HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN):
is_auth_error = True
elif response.status >= HTTP_STATUS_BAD_REQUEST and "errors" in result:
error_messages = []
for err in result["errors"]:
message = err.get("message", "Unknown error")
error_messages.append(message)
extensions = err.get("extensions", {})
status_code = extensions.get("statusCode")
if status_code in (401, 403) or "unauthorized" in message.lower():
is_auth_error = True
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

LOGGER.info("Authentication error detected, attempting token refresh")
try:
self._refresh_in_progress = True
new_token = await self._token_refresh_callback()
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.
# Retry the request once
LOGGER.debug("Retrying request with refreshed token")
response = await self._session.request(
method=method,
url=url,
headers=retry_headers,
json=data,
)
result = await response.json()
LOGGER.debug("Response after retry: %r", result)
except Exception as refresh_exception:
LOGGER.error("Token refresh failed: %s", refresh_exception)
_raise_authentication_error()
Comment on lines +616 to +618
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.
finally:
self._refresh_in_progress = False

# Check for HTTP errors
if response.status in (HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN):
Expand Down
109 changes: 109 additions & 0 deletions custom_components/integration_linear/oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""OAuth2 token refresh helper for Linear Integration."""

from __future__ import annotations

import time
from typing import TYPE_CHECKING, Any

from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)

from .config_flow import LINEAR_AUTHORIZE_URL, LINEAR_CLIENT_ID, LINEAR_TOKEN_URL
from .const import DOMAIN, LOGGER

if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant


async def async_get_valid_token(
hass: HomeAssistant,
entry: ConfigEntry,
) -> str:
"""
Get a valid access token, refreshing if necessary.
Args:
hass: Home Assistant instance
entry: Config entry containing OAuth token
Returns:
Valid access token
Raises:
ValueError: If entry doesn't use OAuth or token refresh fails
"""
# Check if this entry uses OAuth (has token in data, not CONF_API_TOKEN)
if "token" not in entry.data:
msg = "Entry does not use OAuth authentication"
raise ValueError(msg)

token = entry.data["token"]
access_token = token.get("access_token", "")

# Check if token is expired or about to expire (within 60 seconds)
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")
Comment on lines +47 to +50
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.
token = await async_refresh_token(hass, entry)

return token.get("access_token", access_token)


async def async_refresh_token(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""
Refresh the OAuth token and update the config entry.
Args:
hass: Home Assistant instance
entry: Config entry containing OAuth token
Returns:
Updated token data
Raises:
ValueError: If entry doesn't use OAuth or token refresh fails
"""
# Check if this entry uses OAuth (has token in data, not CONF_API_TOKEN)
if "token" not in entry.data:
msg = "Entry does not use OAuth authentication"
raise ValueError(msg)

# Get current token
current_token = entry.data["token"]

# Create the OAuth2 implementation (same as in config flow)
# This is a local implementation, so we recreate it
implementation = LocalOAuth2ImplementationWithPkce(
hass,
DOMAIN,
LINEAR_CLIENT_ID,
authorize_url=LINEAR_AUTHORIZE_URL,
token_url=LINEAR_TOKEN_URL,
client_secret="", # Empty for PKCE public client
code_verifier_length=128,
)

# Refresh the token using the implementation
try:
new_token = await implementation.async_refresh_token(current_token)
except Exception as exception:
LOGGER.error("Failed to refresh OAuth token: %s", exception)
error_msg = f"Token refresh failed: {exception}"
raise ValueError(error_msg) from exception
else:
# Update the config entry with the new token
entry_data = dict(entry.data)
entry_data["token"] = new_token
hass.config_entries.async_update_entry(entry, data=entry_data)

LOGGER.debug("OAuth token refreshed successfully")
return new_token

Loading