feat(oauth): Phase C — social login on OAuth consent screen

Enable dashboard users (GitHub/Google social login) to approve OAuth
consent without needing to create and paste an API key. Adds mhu_ user
key support, session-based consent with __session__ marker, social login
links on the consent page, and return URL redirect after login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-24 21:13:17 +03:30
parent d3bcb31053
commit 479d0c7252
3 changed files with 240 additions and 29 deletions

View File

@@ -2334,6 +2334,20 @@ async def auth_provider_redirect(request: Request) -> Response:
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true", secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
samesite="lax", samesite="lax",
) )
# Save return URL if provided (for OAuth consent flow redirect-back)
next_url = request.query_params.get("next", "")
if next_url:
response.set_cookie(
key="mcp_auth_next",
value=next_url,
max_age=600,
httponly=True,
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
samesite="lax",
path="/",
)
return response return response
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
logger.error("OAuth redirect failed for %s: %s", provider, e) logger.error("OAuth redirect failed for %s: %s", provider, e)
@@ -2445,15 +2459,25 @@ async def auth_callback(request: Request) -> Response:
role=user.get("role", "user"), role=user.get("role", "user"),
) )
response = RedirectResponse(url="/dashboard", status_code=303) # Check for return URL (OAuth consent flow redirect-back)
next_url = request.cookies.get("mcp_auth_next", "")
# Validate: must be relative URL or same-origin to prevent open redirect
if next_url and next_url.startswith("/"):
redirect_to = next_url
else:
redirect_to = "/dashboard"
response = RedirectResponse(url=redirect_to, status_code=303)
dashboard_auth = get_dashboard_auth() dashboard_auth = get_dashboard_auth()
dashboard_auth.set_session_cookie(response, token) dashboard_auth.set_session_cookie(response, token)
response.delete_cookie(key="oauth_state") response.delete_cookie(key="oauth_state")
response.delete_cookie(key="mcp_auth_next", path="/")
logger.info( logger.info(
"OAuth login successful: %s via %s", "OAuth login successful: %s via %s (redirect=%s)",
user["email"], user["email"],
provider, provider,
redirect_to,
) )
return response return response

View File

@@ -73,7 +73,52 @@
{% endif %} {% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- API Key Input --> <!-- Authentication -->
{% if session_user %}
<!-- Session-based consent (user already logged in) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<div>
<p class="font-medium text-green-800 dark:text-green-300">
{% if lang == 'fa' %}وارد شده به عنوان{% else %}Logged in as{% endif %}
</p>
<p class="text-sm text-green-700 dark:text-green-400">
{{ session_user.name or session_user.email or 'User' }}
{% if session_user.email %}
<span class="text-green-600 dark:text-green-500">({{ session_user.email }})</span>
{% endif %}
</p>
</div>
</div>
</div>
<input type="hidden" name="api_key" value="__session__" id="session_marker">
<p class="text-xs text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
یا <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">با کلید API وارد شوید</a>
{% else %}
Or <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">enter an API key instead</a>
{% endif %}
</p>
</div>
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }}
</label>
<input type="password" id="api_key_input" name="api_key_manual"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
</div>
{% else %}
<!-- API key mode (no active session) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}> <div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }} {{ t.enter_api_key }}
@@ -91,7 +136,33 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}> <p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
{{ t.api_key_note }} {{ t.api_key_note }}
</p> </p>
{% if return_url %}
<!-- Social login link -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}
یا با حساب کاربری وارد شوید:
{% else %}
Or log in with your account:
{% endif %}
</p>
<div class="flex gap-2 mt-2">
<a href="/auth/github?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<a href="/auth/google?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 text-gray-700 text-sm rounded-lg border border-gray-300 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</a>
</div>
</div>
{% endif %}
</div> </div>
{% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
@@ -131,29 +202,51 @@
{% block scripts %} {% block scripts %}
<script> <script>
function showApiKeyInput() {
// Switch from session mode to API key input mode
document.getElementById('api-key-section').classList.remove('hidden');
// Remove the __session__ hidden input
var hiddenInput = document.getElementById('session_marker');
if (hiddenInput) hiddenInput.remove();
// Make the manual input the actual api_key field
var manualInput = document.getElementById('api_key_input');
if (manualInput) {
manualInput.name = 'api_key';
manualInput.required = true;
manualInput.focus();
}
}
// Client-side validation // Client-side validation
document.getElementById('authForm').addEventListener('submit', function(e) { document.getElementById('authForm').addEventListener('submit', function(e) {
const apiKey = document.getElementById('api_key').value; var action = e.submitter.value;
const action = e.submitter.value;
if (action === 'approve') { if (action === 'approve') {
// Session-based consent — no API key validation needed
var sessionInput = document.getElementById('session_marker');
if (sessionInput) {
return true;
}
var apiKeyEl = document.getElementById('api_key') || document.getElementById('api_key_input');
var apiKey = apiKeyEl ? apiKeyEl.value : '';
if (!apiKey || apiKey.length < 10) { if (!apiKey || apiKey.length < 10) {
e.preventDefault(); e.preventDefault();
const lang = '{{ lang }}'; var lang = '{{ lang }}';
const message = lang === 'fa' var message = lang === 'fa'
? 'لطفاً یک API Key معتبر وارد کنید' ? 'لطفاً یک API Key معتبر وارد کنید'
: 'Please enter a valid API key'; : 'Please enter a valid API key';
alert(message); alert(message);
return false; return false;
} }
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_')) { if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
const lang = '{{ lang }}'; var lang2 = '{{ lang }}';
const message = lang === 'fa' var msg = lang2 === 'fa'
? 'فرمت API Key غیرمعمول به نظر می‌رسد. ادامه می‌دهید؟' ? 'فرمت API Key غیرمعمول به نظر می‌رسد. ادامه می‌دهید؟'
: 'The API key format looks unusual. Continue anyway?'; : 'The API key format looks unusual. Continue anyway?';
const confirm = window.confirm(message); if (!window.confirm(msg)) {
if (!confirm) {
e.preventDefault(); e.preventDefault();
return false; return false;
} }
@@ -161,13 +254,15 @@
} }
}); });
// Focus effect // Focus effect for API key input (if present)
const apiKeyInput = document.getElementById('api_key'); var apiKeyInput = document.getElementById('api_key');
apiKeyInput.addEventListener('focus', function() { if (apiKeyInput) {
this.parentElement.classList.add('ring-2', 'ring-purple-200'); apiKeyInput.addEventListener('focus', function() {
}); this.parentElement.classList.add('ring-2', 'ring-purple-200');
apiKeyInput.addEventListener('blur', function() { });
this.parentElement.classList.remove('ring-2', 'ring-purple-200'); apiKeyInput.addEventListener('blur', function() {
}); this.parentElement.classList.remove('ring-2', 'ring-purple-200');
});
}
</script> </script>
{% endblock %} {% endblock %}

110
server.py
View File

@@ -2394,6 +2394,37 @@ async def oauth_authorize(request: Request):
# Generate CSRF token # Generate CSRF token
csrf_token = csrf_manager.generate_token() csrf_token = csrf_manager.generate_token()
# Detect existing dashboard session for session-based consent
session_user = None
try:
from core.dashboard.auth import get_dashboard_auth
dashboard_auth = get_dashboard_auth()
# Try OAuth user session first (GitHub/Google login)
user_session = dashboard_auth.get_user_session_from_request(request)
if user_session and user_session.get("user_id"):
session_user = {
"user_id": user_session["user_id"],
"email": user_session.get("email", ""),
"name": user_session.get("name", ""),
"role": user_session.get("role", "user"),
"type": "oauth_user",
}
else:
# Try admin session
admin_session = dashboard_auth.get_session_from_request(request)
if admin_session:
session_user = {
"user_id": "admin",
"email": "",
"name": "Admin",
"role": "admin",
"type": admin_session.user_type,
}
except Exception as e:
logger.debug(f"No dashboard session detected for OAuth consent: {e}")
# Get client info # Get client info
from core.oauth import get_client_registry from core.oauth import get_client_registry
@@ -2432,6 +2463,8 @@ async def oauth_authorize(request: Request):
"csrf_token": csrf_token, "csrf_token": csrf_token,
"lang": lang, # Language code (en/fa) "lang": lang, # Language code (en/fa)
"t": translations, # All translations for this language "t": translations, # All translations for this language
"session_user": session_user,
"return_url": str(request.url),
}, },
) )
@@ -2556,10 +2589,12 @@ async def oauth_authorize_confirm(request: Request):
state=params.get("state"), state=params.get("state"),
) )
# Validate API Key # Validate authentication (API Key or dashboard session)
api_key = params.get("api_key") api_key = params.get("api_key", "")
if not api_key: if not api_key:
raise OAuthError(error="invalid_request", error_description="API Key is required") raise OAuthError(
error="invalid_request", error_description="Authentication is required"
)
api_key_id = None api_key_id = None
api_key_project_id = None api_key_project_id = None
@@ -2567,13 +2602,12 @@ async def oauth_authorize_confirm(request: Request):
granted_scope = validated["scope"] granted_scope = validated["scope"]
try: try:
# Try as regular API key first # Path 1: Project API key (cmp_)
if api_key.startswith("cmp_"): if api_key.startswith("cmp_"):
# Validate without project/scope check (we'll use its values)
key_id = api_key_manager.validate_key( key_id = api_key_manager.validate_key(
api_key, api_key,
project_id="*", # Skip project check project_id="*",
required_scope="read", # Skip scope check required_scope="read",
skip_project_check=True, skip_project_check=True,
) )
api_key_info = api_key_manager.keys.get(key_id) api_key_info = api_key_manager.keys.get(key_id)
@@ -2581,9 +2615,67 @@ async def oauth_authorize_confirm(request: Request):
api_key_project_id = api_key_info.project_id api_key_project_id = api_key_info.project_id
api_key_scope = api_key_info.scope api_key_scope = api_key_info.scope
# Try as Master API Key # Path 2: User API key (mhu_)
elif api_key.startswith("mhu_"):
from core.user_keys import get_user_key_manager
user_key_mgr = get_user_key_manager()
key_info = await user_key_mgr.validate_key(api_key)
if key_info:
api_key_id = f"user:{key_info['user_id']}"
api_key_project_id = "*"
api_key_scope = key_info.get("scopes", "read write")
logger.info(
f"OAuth authorization: User API key validated - "
f"user_id={key_info['user_id']}, scope={api_key_scope}"
)
else:
raise OAuthError(
error="access_denied",
error_description="Invalid or expired User API Key.",
)
# Path 3: Dashboard session (social login)
elif api_key == "__session__":
from core.dashboard.auth import get_dashboard_auth
dashboard_auth = get_dashboard_auth()
# Try OAuth user session (GitHub/Google)
user_session = dashboard_auth.get_user_session_from_request(request)
if user_session and user_session.get("user_id"):
user_role = user_session.get("role", "user")
api_key_id = f"user:{user_session['user_id']}"
api_key_project_id = "*"
if user_role == "admin":
api_key_scope = "read write admin"
else:
api_key_scope = "read write"
logger.info(
f"OAuth authorization: Session-based consent - "
f"user_id={user_session['user_id']}, "
f"email={user_session.get('email', '')}, "
f"role={user_role}, scope={api_key_scope}"
)
else:
# Try admin session
admin_session = dashboard_auth.get_session_from_request(request)
if admin_session:
api_key_id = f"admin:{admin_session.session_id}"
api_key_project_id = "*"
api_key_scope = "read write admin"
logger.info(
f"OAuth authorization: Admin session consent - "
f"type={admin_session.user_type}"
)
else:
raise OAuthError(
error="access_denied",
error_description="Session expired. Please log in again.",
)
# Path 4: Master API Key
elif auth_manager.validate_master_key(api_key): elif auth_manager.validate_master_key(api_key):
# Master key → Full access
api_key_id = "master" api_key_id = "master"
api_key_project_id = "*" api_key_project_id = "*"
api_key_scope = "read write admin" api_key_scope = "read write admin"