Skip to content

Commit 8065bf7

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 a02a04b commit 8065bf7

File tree

16 files changed

+1367
-191
lines changed

16 files changed

+1367
-191
lines changed

mcpgateway/admin_ui/admin.js

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

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

153153
Admin.editGateway = editGateway;
154+
Admin.openCredentialModal = openCredentialModal;
154155
Admin.refreshGatewayTools = refreshGatewayTools;
155156
Admin.refreshToolsForSelectedGateways = refreshToolsForSelectedGateways;
157+
Admin.revokeCredential = revokeCredential;
158+
Admin.submitCredential = submitCredential;
156159
Admin.testGateway = testGateway;
157160
Admin.viewGateway = viewGateway;
158161

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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# -*- coding: utf-8 -*-
2+
"""Add user_gateway_credentials table for per-user personal credentials
3+
4+
Revision ID: a1b2c3d4e5f6
5+
Revises: z1a2b3c4d5e6
6+
Create Date: 2026-04-02 10:00:00.000000
7+
8+
"""
9+
10+
# Third-Party
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from sqlalchemy import inspect
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "a1b2c3d4e5f6"
17+
down_revision = "z1a2b3c4d5e6"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
conn = op.get_bind()
24+
inspector = inspect(conn)
25+
existing_tables = inspector.get_table_names()
26+
27+
if "user_gateway_credentials" not in existing_tables:
28+
op.create_table(
29+
"user_gateway_credentials",
30+
sa.Column("id", sa.String(36), primary_key=True),
31+
sa.Column("gateway_id", sa.String(36), sa.ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False),
32+
sa.Column("app_user_email", sa.String(255), sa.ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False),
33+
sa.Column("credential_type", sa.String(50), nullable=False),
34+
sa.Column("credential_value", sa.Text(), nullable=False),
35+
sa.Column("label", sa.String(255), nullable=True),
36+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
37+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
38+
sa.UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),
39+
)
40+
41+
existing_indexes = {idx["name"] for idx in inspector.get_indexes("user_gateway_credentials")}
42+
if "idx_user_credentials_gateway" not in existing_indexes:
43+
op.create_index("idx_user_credentials_gateway", "user_gateway_credentials", ["gateway_id"])
44+
if "idx_user_credentials_email" not in existing_indexes:
45+
op.create_index("idx_user_credentials_email", "user_gateway_credentials", ["app_user_email"])
46+
47+
48+
def downgrade() -> None:
49+
op.drop_index("idx_user_credentials_email", table_name="user_gateway_credentials")
50+
op.drop_index("idx_user_credentials_gateway", table_name="user_gateway_credentials")
51+
op.drop_table("user_gateway_credentials")

mcpgateway/db.py

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

4674+
# Relationship with per-user personal credentials
4675+
user_credentials: Mapped[List["UserGatewayCredential"]] = relationship("UserGatewayCredential", back_populates="gateway", cascade="all, delete-orphan")
4676+
46744677
# Relationship with registered OAuth clients (DCR)
46754678

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

50605063

5064+
class UserGatewayCredential(Base):
5065+
"""ORM model for per-user personal credentials (API keys, PATs, basic auth) for gateways.
5066+
5067+
Unlike OAuthToken which stores tokens obtained via OAuth flows, this model stores
5068+
credentials that users manually provide for gateways where OAuth is not supported
5069+
(e.g., API keys, personal access tokens, basic auth credentials).
5070+
"""
5071+
5072+
__tablename__ = "user_gateway_credentials"
5073+
5074+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5075+
gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False)
5076+
app_user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False)
5077+
credential_type: Mapped[str] = mapped_column(String(50), nullable=False) # "api_key", "bearer_token", "basic_auth"
5078+
credential_value: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
5079+
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5080+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
5081+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
5082+
5083+
# Relationships
5084+
gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="user_credentials")
5085+
app_user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[app_user_email])
5086+
5087+
# Unique constraint: one credential per user per gateway
5088+
__table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_credential_gateway_user"),)
5089+
5090+
50615091
class OAuthState(Base):
50625092
"""ORM model for OAuth authorization states with TTL for CSRF protection."""
50635093

mcpgateway/main.py

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

11152+
# Include personal credential router
11153+
try:
11154+
# First-Party
11155+
from mcpgateway.routers.credential_router import credential_router
11156+
11157+
app.include_router(credential_router)
11158+
logger.info("Credential router included")
11159+
except ImportError:
11160+
logger.debug("Credential router not available")
11161+
1115211162
# Include reverse proxy router if enabled
1115311163
if settings.mcpgateway_reverse_proxy_enabled:
1115411164
try:

0 commit comments

Comments
 (0)