Skip to content
Merged
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
16 changes: 16 additions & 0 deletions mcpgateway/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5774,6 +5774,8 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
oauth_client_id = str(form.get("oauth_client_id", ""))
oauth_client_secret = str(form.get("oauth_client_secret", ""))
oauth_username = str(form.get("oauth_username", ""))
oauth_password = str(form.get("oauth_password", ""))
oauth_scopes_str = str(form.get("oauth_scopes", ""))

# If any OAuth field is provided, assemble oauth_config
Expand All @@ -5797,6 +5799,12 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
encryption = get_oauth_encryption(settings.auth_encryption_secret)
oauth_config["client_secret"] = encryption.encrypt_secret(oauth_client_secret)

# Add username and password for password grant type
if oauth_username:
oauth_config["username"] = oauth_username
if oauth_password:
oauth_config["password"] = oauth_password

# Parse scopes (comma or space separated)
if oauth_scopes_str:
scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
Expand Down Expand Up @@ -6077,6 +6085,8 @@ async def admin_edit_gateway(
oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
oauth_client_id = str(form.get("oauth_client_id", ""))
oauth_client_secret = str(form.get("oauth_client_secret", ""))
oauth_username = str(form.get("oauth_username", ""))
oauth_password = str(form.get("oauth_password", ""))
oauth_scopes_str = str(form.get("oauth_scopes", ""))

# If any OAuth field is provided, assemble oauth_config
Expand All @@ -6100,6 +6110,12 @@ async def admin_edit_gateway(
encryption = get_oauth_encryption(settings.auth_encryption_secret)
oauth_config["client_secret"] = encryption.encrypt_secret(oauth_client_secret)

# Add username and password for password grant type
if oauth_username:
oauth_config["username"] = oauth_username
if oauth_password:
oauth_config["password"] = oauth_password

# Parse scopes (comma or space separated)
if oauth_scopes_str:
scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
Expand Down
101 changes: 101 additions & 0 deletions mcpgateway/services/oauth_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ async def get_access_token(self, credentials: Dict[str, Any]) -> str:

if grant_type == "client_credentials":
return await self._client_credentials_flow(credentials)
if grant_type == "password":
return await self._password_flow(credentials)
if grant_type == "authorization_code":
# For authorization code flow in gateway initialization, we need to handle this differently
# Since this is called during gateway setup, we'll try to use client credentials as fallback
Expand Down Expand Up @@ -282,6 +284,105 @@ async def _client_credentials_flow(self, credentials: Dict[str, Any]) -> str:
# This should never be reached due to the exception above, but needed for type safety
raise OAuthError("Failed to obtain access token after all retry attempts")

async def _password_flow(self, credentials: Dict[str, Any]) -> str:
"""Resource Owner Password Credentials flow (RFC 6749 Section 4.3).

This flow is used when the application can directly handle the user's credentials,
such as with trusted first-party applications or legacy integrations like Keycloak.

Args:
credentials: OAuth configuration with client_id, optional client_secret, token_url, username, password

Returns:
Access token string

Raises:
OAuthError: If token acquisition fails after all retries
"""
client_id = credentials.get("client_id")
client_secret = credentials.get("client_secret")
token_url = credentials["token_url"]
username = credentials.get("username")
password = credentials.get("password")
scopes = credentials.get("scopes", [])

if not username or not password:
raise OAuthError("Username and password are required for password grant type")

# Decrypt client secret if it's encrypted and present
if client_secret and len(client_secret) > 50: # Simple heuristic: encrypted secrets are longer
try:
settings = get_settings()
encryption = get_oauth_encryption(settings.auth_encryption_secret)
decrypted_secret = encryption.decrypt_secret(client_secret)
if decrypted_secret:
client_secret = decrypted_secret
logger.debug("Successfully decrypted client secret")
else:
logger.warning("Failed to decrypt client secret, using encrypted version")
except Exception as e:
logger.warning(f"Failed to decrypt client secret: {e}, using encrypted version")

# Prepare token request data
token_data = {
"grant_type": "password",
"username": username,
"password": password,
}

# Add client_id (required by most providers including Keycloak)
if client_id:
token_data["client_id"] = client_id

# Add client_secret if present (some providers require it, others don't)
if client_secret:
token_data["client_secret"] = client_secret

if scopes:
token_data["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes

# Fetch token with retries
for attempt in range(self.max_retries):
try:
async with aiohttp.ClientSession() as session:
async with session.post(token_url, data=token_data, timeout=aiohttp.ClientTimeout(total=self.request_timeout)) as response:
response.raise_for_status()

# Handle both JSON and form-encoded responses
content_type = response.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type:
# Parse form-encoded response
text_response = await response.text()
token_response = {}
for pair in text_response.split("&"):
if "=" in pair:
key, value = pair.split("=", 1)
token_response[key] = value
else:
# Try JSON response
try:
token_response = await response.json()
except Exception as e:
logger.warning(f"Failed to parse JSON response: {e}")
# Fallback to text parsing
text_response = await response.text()
token_response = {"raw_response": text_response}

if "access_token" not in token_response:
raise OAuthError(f"No access_token in response: {token_response}")

logger.info("Successfully obtained access token via password grant")
return token_response["access_token"]

except aiohttp.ClientError as e:
logger.warning(f"Token request attempt {attempt + 1} failed: {str(e)}")
if attempt == self.max_retries - 1:
raise OAuthError(f"Failed to obtain access token after {self.max_retries} attempts: {str(e)}")
await asyncio.sleep(2**attempt) # Exponential backoff

# This should never be reached due to the exception above, but needed for type safety
raise OAuthError("Failed to obtain access token after all retry attempts")

async def get_authorization_url(self, credentials: Dict[str, Any]) -> Dict[str, str]:
"""Get authorization URL for user delegation flow.

Expand Down
74 changes: 74 additions & 0 deletions mcpgateway/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9306,6 +9306,8 @@ function handleAuthTypeChange() {
function handleOAuthGrantTypeChange() {
const grantType = this.value;
const authCodeFields = safeGetElement("oauth-auth-code-fields-gw");
const usernameField = safeGetElement("oauth-username-field-gw");
const passwordField = safeGetElement("oauth-password-field-gw");

if (authCodeFields) {
if (grantType === "authorization_code") {
Expand Down Expand Up @@ -9333,11 +9335,48 @@ function handleOAuthGrantTypeChange() {
});
}
}

// Handle password grant type fields
if (usernameField && passwordField) {
if (grantType === "password") {
usernameField.style.display = "block";
passwordField.style.display = "block";

// Make username and password required for password grant
const usernameInput = safeGetElement("oauth-username-gw");
const passwordInput = safeGetElement("oauth-password-gw");
if (usernameInput) {
usernameInput.required = true;
}
if (passwordInput) {
passwordInput.required = true;
}

console.log(
"Password grant flow selected - username and password are now required",
);
} else {
usernameField.style.display = "none";
passwordField.style.display = "none";

// Remove required validation for hidden fields
const usernameInput = safeGetElement("oauth-username-gw");
const passwordInput = safeGetElement("oauth-password-gw");
if (usernameInput) {
usernameInput.required = false;
}
if (passwordInput) {
passwordInput.required = false;
}
}
}
}

function handleEditOAuthGrantTypeChange() {
const grantType = this.value;
const authCodeFields = safeGetElement("oauth-auth-code-fields-gw-edit");
const usernameField = safeGetElement("oauth-username-field-edit");
const passwordField = safeGetElement("oauth-password-field-edit");

if (authCodeFields) {
if (grantType === "authorization_code") {
Expand Down Expand Up @@ -9365,6 +9404,41 @@ function handleEditOAuthGrantTypeChange() {
});
}
}

// Handle password grant type fields
if (usernameField && passwordField) {
if (grantType === "password") {
usernameField.style.display = "block";
passwordField.style.display = "block";

// Make username and password required for password grant
const usernameInput = safeGetElement("oauth-username-gw-edit");
const passwordInput = safeGetElement("oauth-password-gw-edit");
if (usernameInput) {
usernameInput.required = true;
}
if (passwordInput) {
passwordInput.required = true;
}

console.log(
"Password grant flow selected - username and password are now required",
);
} else {
usernameField.style.display = "none";
passwordField.style.display = "none";

// Remove required validation for hidden fields
const usernameInput = safeGetElement("oauth-username-gw-edit");
const passwordInput = safeGetElement("oauth-password-gw-edit");
if (usernameInput) {
usernameInput.required = false;
}
if (passwordInput) {
passwordInput.required = false;
}
}
}
}

function setupSchemaModeHandlers() {
Expand Down
78 changes: 78 additions & 0 deletions mcpgateway/templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -4564,6 +4564,9 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">
<option value="client_credentials">
Client Credentials (Machine-to-Machine)
</option>
<option value="password">
Resource Owner Password Credentials (Keycloak/Legacy)
</option>
</select>
</div>

Expand Down Expand Up @@ -4618,6 +4621,42 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">
</p>
</div>

<div id="oauth-username-field-gw" style="display: none;">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Username <span class="text-red-500">*</span> <span class="text-gray-500">(for password grant)</span>
</label>
<input
type="text"
name="oauth_username"
id="oauth-username-gw"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
placeholder="systemadmin@system.com"
/>
<p class="mt-1 text-sm text-gray-500">
Required for Resource Owner Password Credentials grant type (Keycloak).
</p>
</div>

<div id="oauth-password-field-gw" style="display: none;">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password <span class="text-red-500">*</span> <span class="text-gray-500">(for password grant)</span>
</label>
<input
type="password"
name="oauth_password"
id="oauth-password-gw"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
placeholder="Enter password"
/>
<p class="mt-1 text-sm text-gray-500">
Required for Resource Owner Password Credentials grant type (Keycloak).
</p>
</div>

<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
Expand Down Expand Up @@ -7252,6 +7291,9 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
<option value="client_credentials">
Client Credentials (Machine-to-Machine)
</option>
<option value="password">
Resource Owner Password Credentials (Keycloak/Legacy)
</option>
</select>
</div>

Expand Down Expand Up @@ -7312,6 +7354,42 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
</p>
</div>

<div id="oauth-username-field-edit" style="display: none;">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Username <span class="text-red-500">*</span> <span class="text-gray-500">(for password grant)</span>
</label>
<input
type="text"
name="oauth_username"
id="oauth-username-gw-edit"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
placeholder="systemadmin@system.com"
/>
<p class="mt-1 text-sm text-gray-500">
Required for Resource Owner Password Credentials grant type (Keycloak).
</p>
</div>

<div id="oauth-password-field-edit" style="display: none;">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password <span class="text-red-500">*</span> <span class="text-gray-500">(for password grant)</span>
</label>
<input
type="password"
name="oauth_password"
id="oauth-password-gw-edit"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
placeholder="Enter password"
/>
<p class="mt-1 text-sm text-gray-500">
Required for Resource Owner Password Credentials grant type (Keycloak).
</p>
</div>

<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
Expand Down
Loading
Loading