Skip to content

Commit 27c3545

Browse files
author
Olivier Gintrand
committed
feat(auth): per-user personal credential store for gateway authentication
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent bc88d59 commit 27c3545

16 files changed

Lines changed: 1427 additions & 220 deletions

mcpgateway/admin_ui/admin.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,14 @@ Admin.handleSubmitWithConfirmation = handleSubmitWithConfirmation;
153153
Admin.handleDeleteSubmit = handleDeleteSubmit;
154154

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

158158
Admin.editGateway = editGateway;
159+
Admin.openCredentialModal = openCredentialModal;
159160
Admin.refreshGatewayTools = refreshGatewayTools;
160161
Admin.refreshToolsForSelectedGateways = refreshToolsForSelectedGateways;
162+
Admin.revokeCredential = revokeCredential;
163+
Admin.submitCredential = submitCredential;
161164
Admin.testGateway = testGateway;
162165
Admin.viewGateway = viewGateway;
163166

mcpgateway/admin_ui/gateways.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,3 +1936,106 @@ export const refreshToolsForSelectedGateways = async function(buttonEl) {
19361936
reloadAssociatedItems();
19371937
}
19381938
}
1939+
1940+
// ---------------------------------------------------------------------------
1941+
// Personal Credential Management
1942+
// ---------------------------------------------------------------------------
1943+
1944+
/**
1945+
* Open the credential modal for a gateway, checking current credential status.
1946+
*/
1947+
export const openCredentialModal = async function (gatewayId, gatewayName) {
1948+
document.getElementById("credential-gateway-id").value = gatewayId;
1949+
document.getElementById("credential-gateway-name").textContent = gatewayName || gatewayId;
1950+
document.getElementById("credential-value").value = "";
1951+
document.getElementById("credential-label").value = "";
1952+
document.getElementById("credential-type").value = "api_key";
1953+
const statusEl = document.getElementById("credential-status");
1954+
statusEl.classList.add("hidden");
1955+
1956+
// Check if user already has a credential for this gateway
1957+
try {
1958+
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
1959+
headers: { Accept: "application/json" },
1960+
});
1961+
if (res.ok) {
1962+
const data = await res.json();
1963+
if (data.has_credential) {
1964+
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>`;
1965+
statusEl.classList.remove("hidden");
1966+
if (data.credential_type) {
1967+
document.getElementById("credential-type").value = data.credential_type;
1968+
}
1969+
if (data.label) {
1970+
document.getElementById("credential-label").value = data.label;
1971+
}
1972+
}
1973+
}
1974+
} catch (_) {
1975+
// Silently ignore — modal still opens
1976+
}
1977+
1978+
openModal("credential-modal");
1979+
};
1980+
1981+
/**
1982+
* Submit the credential form to store a personal credential.
1983+
*/
1984+
export const submitCredential = async function () {
1985+
const gatewayId = document.getElementById("credential-gateway-id").value;
1986+
const credentialType = document.getElementById("credential-type").value;
1987+
const credentialValue = document.getElementById("credential-value").value;
1988+
const label = document.getElementById("credential-label").value || null;
1989+
1990+
if (!credentialValue) {
1991+
showErrorMessage("Credential value is required");
1992+
return;
1993+
}
1994+
1995+
try {
1996+
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
1997+
method: "POST",
1998+
headers: { "Content-Type": "application/json", Accept: "application/json" },
1999+
body: JSON.stringify({
2000+
credential_type: credentialType,
2001+
credential_value: credentialValue,
2002+
label: label,
2003+
}),
2004+
});
2005+
const data = await res.json();
2006+
if (res.ok && data.success) {
2007+
showSuccessMessage("Personal credential stored successfully");
2008+
closeModal("credential-modal");
2009+
} else {
2010+
showErrorMessage(data.detail || data.message || "Failed to store credential");
2011+
}
2012+
} catch (err) {
2013+
showErrorMessage(`Failed to store credential: ${err.message}`);
2014+
}
2015+
};
2016+
2017+
/**
2018+
* Revoke the stored credential for the current gateway.
2019+
*/
2020+
export const revokeCredential = async function () {
2021+
const gatewayId = document.getElementById("credential-gateway-id").value;
2022+
if (!confirm("Are you sure you want to revoke your personal credential for this gateway?")) {
2023+
return;
2024+
}
2025+
2026+
try {
2027+
const res = await fetchWithTimeout(`${window.ROOT_PATH}/credentials/${gatewayId}`, {
2028+
method: "DELETE",
2029+
headers: { Accept: "application/json" },
2030+
});
2031+
const data = await res.json();
2032+
if (res.ok && data.success) {
2033+
showSuccessMessage("Personal credential revoked");
2034+
closeModal("credential-modal");
2035+
} else {
2036+
showErrorMessage(data.message || "No credential found to revoke");
2037+
}
2038+
} catch (err) {
2039+
showErrorMessage(`Failed to revoke credential: ${err.message}`);
2040+
}
2041+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# -*- coding: utf-8 -*-
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Add user_gateway_credentials table for per-user personal credentials
4+
5+
Revision ID: a1b2c3d4e5f6
6+
Revises: z1a2b3c4d5e6
7+
Create Date: 2026-04-02 10:00:00.000000
8+
9+
"""
10+
11+
# Third-Party
12+
from alembic import op
13+
import sqlalchemy as sa
14+
from sqlalchemy import inspect
15+
16+
# revision identifiers, used by Alembic.
17+
revision = "a1b2c3d4e5f6"
18+
down_revision = "z1a2b3c4d5e6"
19+
branch_labels = None
20+
depends_on = None
21+
22+
23+
def upgrade() -> None:
24+
conn = op.get_bind()
25+
inspector = inspect(conn)
26+
existing_tables = inspector.get_table_names()
27+
28+
if "user_gateway_credentials" not in existing_tables:
29+
op.create_table(
30+
"user_gateway_credentials",
31+
sa.Column("id", sa.String(36), primary_key=True),
32+
sa.Column("gateway_id", sa.String(36), sa.ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False),
33+
sa.Column("app_user_email", sa.String(255), sa.ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False),
34+
sa.Column("credential_type", sa.String(50), nullable=False),
35+
sa.Column("credential_value", sa.Text(), nullable=False),
36+
sa.Column("label", sa.String(255), nullable=True),
37+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
38+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
39+
sa.UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),
40+
)
41+
42+
existing_indexes = {idx["name"] for idx in inspector.get_indexes("user_gateway_credentials")}
43+
if "idx_user_credentials_gateway" not in existing_indexes:
44+
op.create_index("idx_user_credentials_gateway", "user_gateway_credentials", ["gateway_id"])
45+
if "idx_user_credentials_email" not in existing_indexes:
46+
op.create_index("idx_user_credentials_email", "user_gateway_credentials", ["app_user_email"])
47+
48+
49+
def downgrade() -> None:
50+
op.drop_index("idx_user_credentials_email", table_name="user_gateway_credentials")
51+
op.drop_index("idx_user_credentials_gateway", table_name="user_gateway_credentials")
52+
op.drop_table("user_gateway_credentials")

mcpgateway/db.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4675,6 +4675,9 @@ def team(self) -> Optional[str]:
46754675
# Relationship with OAuth tokens
46764676
oauth_tokens: Mapped[List["OAuthToken"]] = relationship("OAuthToken", back_populates="gateway", cascade="all, delete-orphan")
46774677

4678+
# Relationship with per-user personal credentials
4679+
user_credentials: Mapped[List["UserGatewayCredential"]] = relationship("UserGatewayCredential", back_populates="gateway", cascade="all, delete-orphan")
4680+
46784681
# Relationship with registered OAuth clients (DCR)
46794682

46804683
registered_oauth_clients: Mapped[List["RegisteredOAuthClient"]] = relationship("RegisteredOAuthClient", back_populates="gateway", cascade="all, delete-orphan")
@@ -5212,6 +5215,33 @@ class OAuthToken(Base):
52125215
__table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_oauth_gateway_user"),)
52135216

52145217

5218+
class UserGatewayCredential(Base):
5219+
"""ORM model for per-user personal credentials (API keys, PATs, basic auth) for gateways.
5220+
5221+
Unlike OAuthToken which stores tokens obtained via OAuth flows, this model stores
5222+
credentials that users manually provide for gateways where OAuth is not supported
5223+
(e.g., API keys, personal access tokens, basic auth credentials).
5224+
"""
5225+
5226+
__tablename__ = "user_gateway_credentials"
5227+
5228+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5229+
gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False)
5230+
app_user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False)
5231+
credential_type: Mapped[str] = mapped_column(String(50), nullable=False) # "api_key", "bearer_token", "basic_auth"
5232+
credential_value: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
5233+
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5234+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
5235+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
5236+
5237+
# Relationships
5238+
gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="user_credentials")
5239+
app_user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[app_user_email])
5240+
5241+
# Unique constraint: one credential per user per gateway
5242+
__table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),)
5243+
5244+
52155245
class OAuthState(Base):
52165246
"""ORM model for OAuth authorization states with TTL for CSRF protection."""
52175247

mcpgateway/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11651,6 +11651,16 @@ async def cleanup_import_statuses(max_age_hours: int = 24, user=Depends(get_curr
1165111651
except ImportError:
1165211652
logger.debug("OAuth router not available")
1165311653

11654+
# Include personal credential router
11655+
try:
11656+
# First-Party
11657+
from mcpgateway.routers.credential_router import credential_router
11658+
11659+
app.include_router(credential_router)
11660+
logger.info("Credential router included")
11661+
except ImportError:
11662+
logger.debug("Credential router not available")
11663+
1165411664
# Include reverse proxy router if enabled
1165511665
if settings.mcpgateway_reverse_proxy_enabled:
1165611666
try:

0 commit comments

Comments
 (0)