music-assistant-server

6.8 KBPY
auth.py
6.8 KB171 lines • python
1"""
2OAuth glue between Music Assistant and OneDrive (Microsoft Graph).
3
4The OneDrive SDK just needs a coroutine that returns a valid access token;
5MAOneDriveAuth provides that, refreshing with Microsoft when it expires.
6`authorize()` runs the one-time consent flow and returns a refresh token.
7"""
8
9from __future__ import annotations
10
11import asyncio
12import time
13from typing import TYPE_CHECKING
14from urllib.parse import urlencode
15
16from aiohttp import ClientError
17from music_assistant_models.errors import LoginFailed, ProviderUnavailableError
18
19from music_assistant.constants import CONF_PROVIDERS
20from music_assistant.helpers.oauth import (
21    OAUTH_STEP_TIMEOUT,
22    authorization_code_from_params,
23    hosted_bounce_redirect,
24)
25from music_assistant.models.setup_flow import SetupFlowError
26from music_assistant.providers.filesystem_cloud.base import CONF_REFRESH_TOKEN
27
28from .constants import OAUTH_AUTHORIZE_URL, OAUTH_SCOPE, OAUTH_TOKEN_URL
29
30if TYPE_CHECKING:
31    from music_assistant.mass import MusicAssistant
32    from music_assistant.models.setup_flow import SetupSession
33
34
35async def authorize(session: SetupSession, client_id: str, client_secret: str) -> str:
36    """
37    Run the Microsoft OAuth consent flow via the setup session and return the refresh token.
38
39    :param session: The setup session driving the flow.
40    :param client_id: The user's Microsoft OAuth client (application) ID.
41    :param client_secret: The user's Microsoft OAuth client secret.
42    """
43    # Microsoft only allows pre-registered redirect URIs, so send the user through the fixed
44    # MA callback page which forwards to the local callback URL smuggled along in `state`
45    redirect_uri, state = hosted_bounce_redirect(session.callback_url)
46    params = {
47        "response_type": "code",
48        "client_id": client_id,
49        "scope": OAUTH_SCOPE,
50        "redirect_uri": redirect_uri,
51        "state": state,
52    }
53    result = await session.external(
54        f"{OAUTH_AUTHORIZE_URL}?{urlencode(params)}",
55        step_id="authenticate",
56        expires_in=OAUTH_STEP_TIMEOUT,
57    )
58    code = authorization_code_from_params(result)
59    data = {
60        "grant_type": "authorization_code",
61        "code": code,
62        "client_id": client_id,
63        "client_secret": client_secret,
64        "redirect_uri": redirect_uri,
65        "scope": OAUTH_SCOPE,
66    }
67    try:
68        async with session.mass.http_session.post(OAUTH_TOKEN_URL, data=data) as resp:
69            if resp.status != 200:
70                error_text = await resp.text()
71                raise SetupFlowError(
72                    _friendly_auth_error(error_text)
73                    or f"Failed to exchange authorization code: {error_text}"
74                )
75            token_info = await resp.json()
76    except ClientError as err:
77        raise SetupFlowError(f"Failed to exchange authorization code: {err}") from err
78    if not (refresh_token := token_info.get("refresh_token")):
79        raise SetupFlowError(
80            "Microsoft did not return a refresh token, please retry the authorization"
81        )
82    return str(refresh_token)
83
84
85class MAOneDriveAuth:
86    """Provide OneDrive access tokens using a stored refresh token."""
87
88    def __init__(
89        self,
90        mass: MusicAssistant,
91        instance_id: str,
92        client_id: str,
93        client_secret: str,
94        refresh_token: str,
95    ) -> None:
96        """Initialise the auth helper."""
97        self.mass = mass
98        self._instance_id = instance_id
99        self._client_id = client_id
100        self._client_secret = client_secret
101        self._refresh_token = refresh_token
102        self._access_token: str | None = None
103        self._expires_at: float = 0.0
104        # Microsoft rotates the refresh token on every redemption, so parallel
105        # refreshes with the same token could invalidate it; serialize them
106        self._refresh_lock = asyncio.Lock()
107
108    async def async_get_access_token(self) -> str:
109        """Return a valid access token, refreshing it if needed."""
110        # refresh 60s early so a token never expires mid-request
111        if self._access_token and time.time() < self._expires_at - 60:
112            return self._access_token
113        async with self._refresh_lock:
114            # another waiter may have refreshed while we were queued
115            if self._access_token and time.time() < self._expires_at - 60:
116                return self._access_token
117            return await self._refresh()
118
119    async def _refresh(self) -> str:
120        """Exchange the refresh token for a new access token."""
121        data = {
122            "client_id": self._client_id,
123            "client_secret": self._client_secret,
124            "refresh_token": self._refresh_token,
125            "grant_type": "refresh_token",
126            "scope": OAUTH_SCOPE,
127        }
128        try:
129            async with self.mass.http_session.post(OAUTH_TOKEN_URL, data=data) as resp:
130                if resp.status in (400, 401):
131                    error_text = await resp.text()
132                    raise LoginFailed(
133                        _friendly_auth_error(error_text)
134                        or f"Microsoft token refresh failed: {error_text}"
135                    )
136                resp.raise_for_status()
137                payload = await resp.json()
138        except ClientError as err:
139            # network trouble or a Microsoft outage, not an auth problem
140            raise ProviderUnavailableError(f"Microsoft token refresh failed: {err}") from err
141        self._access_token = str(payload["access_token"])
142        self._expires_at = time.time() + float(payload["expires_in"])
143        # Microsoft rotates the refresh token on every use and the old one only
144        # stays valid for a limited time, so persist the newest to setup_data to
145        # survive restarts
146        if (new_refresh := payload.get("refresh_token")) and new_refresh != self._refresh_token:
147            self._refresh_token = new_refresh
148            self.mass.config.set(
149                f"{CONF_PROVIDERS}/{self._instance_id}/setup_data/{CONF_REFRESH_TOKEN}",
150                self.mass.config.encrypt_string(new_refresh),
151                immediate=True,
152            )
153        return self._access_token
154
155
156def _friendly_auth_error(error_text: str) -> str | None:
157    """Map well-known Microsoft auth errors to an actionable message, if recognized."""
158    if "AADSTS7000222" in error_text:
159        return (
160            "The Microsoft OAuth client secret has expired. Create a new client secret "
161            "in the Azure portal, update it in the provider settings and re-authorize."
162        )
163    if "AADSTS7000215" in error_text:
164        return (
165            "The Microsoft OAuth client secret is invalid. Make sure you copied the "
166            "secret's Value (not the Secret ID) from the Azure portal."
167        )
168    if "invalid_grant" in error_text:
169        return "The OneDrive authorization has expired or was revoked, please re-authorize."
170    return None
171