self.logger.exception("Error during OAuth authorization")
return web.json_response({"error": "Authorization failed"}, status=500)
- async def _handle_auth_callback(self, request: web.Request) -> web.Response:
+ async def _handle_auth_callback(self, request: web.Request) -> web.Response: # noqa: PLR0915
"""Handle OAuth callback."""
try:
code = request.query.get("code")
device_name = f"OAuth ({provider_id})"
token = await self.auth.create_token(auth_result.user, device_name)
- # Check if this is a remote client OAuth flow
if auth_result.return_url and auth_result.return_url.startswith(
"urn:ietf:wg:oauth:2.0:oob:auto:"
):
- # Extract session ID from return URL
session_id = auth_result.return_url.split(":")[-1]
- # Store token in pending sessions
if session_id in self.auth._pending_oauth_sessions:
self.auth._pending_oauth_sessions[session_id] = token
- # Show success page for remote auth
- success_html = """
- <html>
- <head><title>Authentication Successful</title></head>
- <body style="font-family: Arial, sans-serif; text-align: center;
- padding: 50px;">
- <h1 style="color: #4CAF50;">✓ Authentication Successful</h1>
- <p>You have successfully authenticated with Music Assistant.</p>
- <p>You can now close this window and return to your application.</p>
- </body>
- </html>
- """
+ oauth_callback_html_path = str(RESOURCES_DIR.joinpath("oauth_callback.html"))
+ async with aiofiles.open(oauth_callback_html_path) as f:
+ success_html = await f.read()
+
+ success_html = success_html.replace("{TOKEN}", token)
+ success_html = success_html.replace("{REDIRECT_URL}", "about:blank")
+ success_html = success_html.replace("{REQUIRES_CONSENT}", "false")
+
return web.Response(text=success_html, content_type="text/html")
# Determine redirect URL (use return_url from OAuth flow or default to root)
color: var(--text-secondary);
font-size: 15px;
}
+
+ .close-button {
+ display: none;
+ margin-top: 24px;
+ padding: 12px 24px;
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 10px;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .close-button:hover {
+ filter: brightness(1.1);
+ transform: translateY(-1px);
+ }
+
+ .close-button:active {
+ transform: translateY(0);
+ }
+
+ .close-button.show {
+ display: inline-block;
+ }
</style>
</head>
<body>
<div class="callback-container">
<h1 id="status">Login Successful!</h1>
<p id="message">Redirecting...</p>
+ <button class="close-button" id="closeButton" onclick="handleManualClose()">Close Window</button>
</div>
<script>
const statusEl = document.getElementById('status');
const redirectUrl = '{REDIRECT_URL}';
const requiresConsent = {REQUIRES_CONSENT};
- // Detect if running in PWA/standalone mode
- // iOS Safari sets window.navigator.standalone
- // Other browsers support display-mode media query
const isPWA = (window.navigator.standalone === true) ||
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: minimal-ui)').matches;
- console.log('OAuth callback - isPWA:', isPWA, 'opener:', window.opener !== null);
-
- // Validate URL to prevent XSS attacks
- // Note: Server-side validation is the primary security layer
function isValidRedirectUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url, window.location.origin);
- // Allow http, https, and custom mobile app schemes
const allowedProtocols = ['http:', 'https:', 'musicassistant:'];
return allowedProtocols.includes(parsed.protocol);
} catch {
}
}
+ function attemptAutoClose() {
+ window.close();
+ if (window.self !== window.top) {
+ window.self.close();
+ }
+ if (window.opener) {
+ try {
+ window.opener.focus();
+ window.close();
+ } catch (e) {
+ // Silently fail
+ }
+ }
+ }
+
+ function showManualCloseButton() {
+ const closeButton = document.getElementById('closeButton');
+ if (closeButton) {
+ closeButton.classList.add('show');
+ }
+ messageEl.textContent = 'Authentication successful! You can close this window.';
+ }
+
+ function handleManualClose() {
+ attemptAutoClose();
+ setTimeout(() => {
+ if (window.opener && !window.opener.closed) {
+ window.blur();
+ }
+ messageEl.textContent = 'Please close this window manually.';
+ statusEl.textContent = 'Ready to Close';
+ }, 500);
+ }
+
function performRedirect() {
const isPopup = window.opener !== null;
+ const isRemoteAuth = redirectUrl === 'about:blank';
+
+ if (isRemoteAuth) {
+ statusEl.textContent = 'Authentication Successful';
+ messageEl.textContent = 'Closing window...';
+ setTimeout(() => {
+ attemptAutoClose();
+ setTimeout(() => {
+ showManualCloseButton();
+ }, 300);
+ }, 100);
+ return;
+ }
- // PWA mode: Always use direct redirect since popups don't work properly
- // The OAuth flow on iOS PWAs opens in Safari, then redirects back to PWA
- // This means window.opener is always null and localStorage is not shared
+ // PWA mode: OAuth flow on iOS PWAs opens in Safari, then redirects back to PWA
+ // window.opener is null and localStorage is not shared between contexts
if (isPWA) {
statusEl.textContent = 'Login Complete!';
messageEl.textContent = 'Returning to app...';
-
- // Don't store token in localStorage here - it's already in the redirect URL
- // The frontend will extract it from the URL query parameter
- // PWAs on iOS may not share localStorage between Safari and the PWA context
-
- console.log('PWA mode - redirecting to:', redirectUrl);
-
- // Navigate to redirect URL (token is already in URL as query param 'code')
if (isValidRedirectUrl(redirectUrl)) {
window.location.href = redirectUrl;
} else {
- console.warn('Invalid redirect URL, using fallback:', redirectUrl);
window.location.href = '/';
}
return;
}
if (isPopup) {
- // Popup mode - send token to parent and close
statusEl.textContent = 'Login Complete!';
messageEl.textContent = 'Closing popup...';
token: token,
redirectUrl: redirectUrl
}, window.location.origin);
- console.log('Successfully sent postMessage to opener');
} catch (e) {
- console.error('Failed to send postMessage:', e);
+ // Silently fail
}
}
- // Modern browsers allow window.close() for windows opened via window.open()
- // Try to close immediately after sending the message
setTimeout(() => {
- // First try to close the window
- window.close();
-
- // Schedule a check to see if close was successful
- // If the window is still open after 300ms, it means close() was blocked
+ attemptAutoClose();
setTimeout(() => {
- // This will only execute if the window wasn't closed
- messageEl.textContent = 'Authentication successful! You can close this window.';
+ showManualCloseButton();
}, 300);
}, 100);
} else {
- // Same window mode - redirect directly
+ statusEl.textContent = 'Authentication Successful';
+ messageEl.textContent = 'Redirecting...';
localStorage.setItem('auth_token', token);
+
if (isValidRedirectUrl(redirectUrl)) {
- window.location.href = redirectUrl;
+ setTimeout(() => {
+ window.location.href = redirectUrl;
+ }, 500);
} else {
- window.location.href = '/';
+ setTimeout(() => {
+ messageEl.textContent = 'Redirect failed. Please close this window.';
+ showManualCloseButton();
+ }, 1000);
}
+
+ setTimeout(() => {
+ showManualCloseButton();
+ }, 2000);
}
}
window.location.href = '/';
}
- // Check if consent is required
if (requiresConsent) {
- // Show consent banner with domain
try {
const parsed = new URL(redirectUrl);
document.getElementById('redirectDomain').textContent = parsed.origin;
statusEl.textContent = 'Authorization Required';
messageEl.textContent = 'Please review the authorization request above.';
} else {
- // Trusted domain, proceed with redirect
performRedirect();
}
</script>