music-assistant-server
6.8 KB•PY
auth.py
6.8 KB • 171 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