Skip to content
Open
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
5 changes: 4 additions & 1 deletion mcpgateway/admin_ui/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,14 @@ Admin.handleSubmitWithConfirmation = handleSubmitWithConfirmation;
Admin.handleDeleteSubmit = handleDeleteSubmit;

// Gateways
import { editGateway, refreshGatewayTools, refreshToolsForSelectedGateways, testGateway, viewGateway } from "./gateways.js";
import { editGateway, openCredentialModal, refreshGatewayTools, refreshToolsForSelectedGateways, revokeCredential, submitCredential, testGateway, viewGateway } from "./gateways.js";

Admin.editGateway = editGateway;
Admin.openCredentialModal = openCredentialModal;
Admin.refreshGatewayTools = refreshGatewayTools;
Admin.refreshToolsForSelectedGateways = refreshToolsForSelectedGateways;
Admin.revokeCredential = revokeCredential;
Admin.submitCredential = submitCredential;
Admin.testGateway = testGateway;
Admin.viewGateway = viewGateway;

Expand Down
103 changes: 103 additions & 0 deletions mcpgateway/admin_ui/gateways.js
Original file line number Diff line number Diff line change
Expand Up @@ -1936,3 +1936,106 @@ export const refreshToolsForSelectedGateways = async function(buttonEl) {
reloadAssociatedItems();
}
}

// ---------------------------------------------------------------------------
// Personal Credential Management
// ---------------------------------------------------------------------------

/**
* Open the credential modal for a gateway, checking current credential status.
*/
export const openCredentialModal = async function (gatewayId, gatewayName) {
document.getElementById("credential-gateway-id").value = gatewayId;
document.getElementById("credential-gateway-name").textContent = gatewayName || gatewayId;
document.getElementById("credential-value").value = "";
document.getElementById("credential-label").value = "";
document.getElementById("credential-type").value = "api_key";
const statusEl = document.getElementById("credential-status");
statusEl.classList.add("hidden");

// Check if user already has a credential for this gateway
try {
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
headers: { Accept: "application/json" },
});
if (res.ok) {
const data = await res.json();
if (data.has_credential) {
statusEl.innerHTML = `<span class="text-green-600 dark:text-green-400">βœ“ You have a stored <strong>${data.credential_type}</strong> credential${data.label ? ` (${data.label})` : ""}. Submitting will replace it.</span>`;
statusEl.classList.remove("hidden");
if (data.credential_type) {
document.getElementById("credential-type").value = data.credential_type;
}
if (data.label) {
document.getElementById("credential-label").value = data.label;
}
}
}
} catch (_) {
// Silently ignore β€” modal still opens
}

openModal("credential-modal");
};

/**
* Submit the credential form to store a personal credential.
*/
export const submitCredential = async function () {
const gatewayId = document.getElementById("credential-gateway-id").value;
const credentialType = document.getElementById("credential-type").value;
const credentialValue = document.getElementById("credential-value").value;
const label = document.getElementById("credential-label").value || null;

if (!credentialValue) {
showErrorMessage("Credential value is required");
return;
}

try {
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
credential_type: credentialType,
credential_value: credentialValue,
label: label,
}),
});
const data = await res.json();
if (res.ok && data.success) {
showSuccessMessage("Personal credential stored successfully");
closeModal("credential-modal");
} else {
showErrorMessage(data.detail || data.message || "Failed to store credential");
}
} catch (err) {
showErrorMessage(`Failed to store credential: ${err.message}`);
}
};

/**
* Revoke the stored credential for the current gateway.
*/
export const revokeCredential = async function () {
const gatewayId = document.getElementById("credential-gateway-id").value;
if (!confirm("Are you sure you want to revoke your personal credential for this gateway?")) {
return;
}

try {
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
const data = await res.json();
if (res.ok && data.success) {
showSuccessMessage("Personal credential revoked");
closeModal("credential-modal");
} else {
showErrorMessage(data.message || "No credential found to revoke");
}
} catch (err) {
showErrorMessage(`Failed to revoke credential: ${err.message}`);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""Add user_gateway_credentials table for per-user personal credentials

Revision ID: a1b2c3d4e5f6
Revises: z1a2b3c4d5e6
Create Date: 2026-04-02 10:00:00.000000

"""

# Third-Party
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect

# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "z1a2b3c4d5e6"
branch_labels = None
depends_on = None


def upgrade() -> None:
conn = op.get_bind()
inspector = inspect(conn)
existing_tables = inspector.get_table_names()

if "user_gateway_credentials" not in existing_tables:
op.create_table(
"user_gateway_credentials",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("gateway_id", sa.String(36), sa.ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False),
sa.Column("app_user_email", sa.String(255), sa.ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False),
sa.Column("credential_type", sa.String(50), nullable=False),
sa.Column("credential_value", sa.Text(), nullable=False),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),
)

existing_indexes = {idx["name"] for idx in inspector.get_indexes("user_gateway_credentials")}
if "idx_user_credentials_gateway" not in existing_indexes:
op.create_index("idx_user_credentials_gateway", "user_gateway_credentials", ["gateway_id"])
if "idx_user_credentials_email" not in existing_indexes:
op.create_index("idx_user_credentials_email", "user_gateway_credentials", ["app_user_email"])


def downgrade() -> None:
op.drop_index("idx_user_credentials_email", table_name="user_gateway_credentials")
op.drop_index("idx_user_credentials_gateway", table_name="user_gateway_credentials")
op.drop_table("user_gateway_credentials")
30 changes: 30 additions & 0 deletions mcpgateway/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4671,6 +4671,9 @@ def team(self) -> Optional[str]:
# Relationship with OAuth tokens
oauth_tokens: Mapped[List["OAuthToken"]] = relationship("OAuthToken", back_populates="gateway", cascade="all, delete-orphan")

# Relationship with per-user personal credentials
user_credentials: Mapped[List["UserGatewayCredential"]] = relationship("UserGatewayCredential", back_populates="gateway", cascade="all, delete-orphan")

# Relationship with registered OAuth clients (DCR)

registered_oauth_clients: Mapped[List["RegisteredOAuthClient"]] = relationship("RegisteredOAuthClient", back_populates="gateway", cascade="all, delete-orphan")
Expand Down Expand Up @@ -5058,6 +5061,33 @@ class OAuthToken(Base):
__table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_oauth_gateway_user"),)


class UserGatewayCredential(Base):
"""ORM model for per-user personal credentials (API keys, PATs, basic auth) for gateways.

Unlike OAuthToken which stores tokens obtained via OAuth flows, this model stores
credentials that users manually provide for gateways where OAuth is not supported
(e.g., API keys, personal access tokens, basic auth credentials).
"""

__tablename__ = "user_gateway_credentials"

id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False)
app_user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False)
credential_type: Mapped[str] = mapped_column(String(50), nullable=False) # "api_key", "bearer_token", "basic_auth"
credential_value: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)

# Relationships
gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="user_credentials")
app_user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[app_user_email])

# Unique constraint: one credential per user per gateway
__table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),)


class OAuthState(Base):
"""ORM model for OAuth authorization states with TTL for CSRF protection."""

Expand Down
10 changes: 10 additions & 0 deletions mcpgateway/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11155,6 +11155,16 @@ async def cleanup_import_statuses(max_age_hours: int = 24, user=Depends(get_curr
except ImportError:
logger.debug("OAuth router not available")

# Include personal credential router
try:
# First-Party
from mcpgateway.routers.credential_router import credential_router

app.include_router(credential_router)
logger.info("Credential router included")
except ImportError:
logger.debug("Credential router not available")

# Include reverse proxy router if enabled
if settings.mcpgateway_reverse_proxy_enabled:
try:
Expand Down
Loading