From 642d9e96fccfcbab723c2ffd39188720b517278c Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Fri, 21 Aug 2026 17:29:29 -0500 Subject: [PATCH] feat: add OAuth2/OIDC On-Behalf-Of token exchange support --- Extensions/ServiceCollectionExtensions.cs | 13 +- .../Persistence/DatabaseSeederService.cs | 6 +- .../Secrets/CompositeSecretRetriever.cs | 9 +- .../Secrets/TokenExchangeSecretRetriever.cs | 391 ++++++++++++++++++ .../TokenExchangeSecretRetrieverTests.cs | 157 +++++++ .../src/components/servers/ServerModal.tsx | 9 +- .../settings/SecretProvidersTab.tsx | 97 ++++- .../components/SecretProvidersTab.test.tsx | 2 +- 8 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 Infrastructure/Secrets/TokenExchangeSecretRetriever.cs create mode 100644 McpRouter.Tests/TokenExchangeSecretRetrieverTests.cs diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index d5d9245c..6d464208 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -116,7 +116,8 @@ public static void AddMcpRouterServices(this WebApplicationBuilder builder) )); builder.Services.AddSingleton(); - // Register Secret Retrievers (HashiCorp Vault, Windows Registry & Environment) + // Register HttpContextAccessor and Secret Retrievers (HashiCorp Vault, Windows Registry, Environment & OAuth2 Token Exchange) + builder.Services.AddHttpContextAccessor(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(sp => new VaultSecretRetriever( @@ -126,6 +127,16 @@ public static void AddMcpRouterServices(this WebApplicationBuilder builder) )); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + new TokenExchangeSecretRetriever( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService(), + sp.GetService(), + sp.GetService(), + sp.GetService(), + sp.GetService>() + )); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Infrastructure/Persistence/DatabaseSeederService.cs b/Infrastructure/Persistence/DatabaseSeederService.cs index 9295ba56..852cb3d9 100644 --- a/Infrastructure/Persistence/DatabaseSeederService.cs +++ b/Infrastructure/Persistence/DatabaseSeederService.cs @@ -1023,7 +1023,8 @@ private static void EnsureDefaultRows(IDbConnection conn, string provider) { new { ProviderName = "Vault", DisplayName = "HashiCorp Vault (KV v2)", IsEnabled = 1 }, new { ProviderName = "WindowsRegistry", DisplayName = "Windows Registry (DPAPI)", IsEnabled = 1 }, - new { ProviderName = "Environment", DisplayName = "Container Environment", IsEnabled = 1 } + new { ProviderName = "Environment", DisplayName = "Container Environment", IsEnabled = 1 }, + new { ProviderName = "TokenExchange", DisplayName = "OAuth2 / OIDC Token Exchange (OBO)", IsEnabled = 1 } }; foreach (var sp in secretProviders) @@ -1039,7 +1040,8 @@ private static void EnsureDefaultRows(IDbConnection conn, string provider) var authProviders = new[] { new { ProviderName = "ActiveDirectory", DisplayName = "Active Directory", UserHeader = "Remote-User", GroupsHeader = "Remote-Groups", IsEnabled = 1 }, - new { ProviderName = "HeaderAuth", DisplayName = "Configurable Reverse Proxy Header Auth", UserHeader = "Remote-User", GroupsHeader = "Remote-Groups", IsEnabled = 1 } + new { ProviderName = "HeaderAuth", DisplayName = "Configurable Reverse Proxy Header Auth", UserHeader = "Remote-User", GroupsHeader = "Remote-Groups", IsEnabled = 1 }, + new { ProviderName = "PocketID", DisplayName = "PocketID OIDC", UserHeader = "Remote-User", GroupsHeader = "Remote-Groups", IsEnabled = 1 } }; foreach (var ap in authProviders) diff --git a/Infrastructure/Secrets/CompositeSecretRetriever.cs b/Infrastructure/Secrets/CompositeSecretRetriever.cs index 4ca9df94..f229b7a8 100644 --- a/Infrastructure/Secrets/CompositeSecretRetriever.cs +++ b/Infrastructure/Secrets/CompositeSecretRetriever.cs @@ -40,7 +40,14 @@ public CompositeSecretRetriever(IEnumerable retrievers, IMemor { if (string.Equals(retriever.ProviderName, providerName, StringComparison.OrdinalIgnoreCase) || (providerName.Equals("Vault", StringComparison.OrdinalIgnoreCase) && retriever.ProviderName.Equals("HashiCorpVault", StringComparison.OrdinalIgnoreCase)) || - (providerName.Equals("HashiCorpVault", StringComparison.OrdinalIgnoreCase) && retriever.ProviderName.Equals("Vault", StringComparison.OrdinalIgnoreCase))) + (providerName.Equals("HashiCorpVault", StringComparison.OrdinalIgnoreCase) && retriever.ProviderName.Equals("Vault", StringComparison.OrdinalIgnoreCase)) || + (retriever.ProviderName.Equals("TokenExchange", StringComparison.OrdinalIgnoreCase) && + (providerName.Equals("OBO", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("PocketID", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("OAuth2", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("OAuth2TokenExchange", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("AzureAD", StringComparison.OrdinalIgnoreCase) || + providerName.Equals("Okta", StringComparison.OrdinalIgnoreCase)))) { targetRetriever = retriever; break; diff --git a/Infrastructure/Secrets/TokenExchangeSecretRetriever.cs b/Infrastructure/Secrets/TokenExchangeSecretRetriever.cs new file mode 100644 index 00000000..9b3c7e35 --- /dev/null +++ b/Infrastructure/Secrets/TokenExchangeSecretRetriever.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using McpRouter.Infrastructure.Identity; +using McpRouter.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace McpRouter.Infrastructure.Secrets +{ + public class TokenExchangeSecretRetriever : ISecretRetriever + { + private readonly IHttpClientFactory? _httpClientFactory; + private readonly IMemoryCache? _cache; + private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly ISecretProviderRepository? _secretRepo; + private readonly IAuthProviderRepository? _authRepo; + private readonly IConfiguration? _config; + private readonly ILogger? _logger; + + public string ProviderName => "TokenExchange"; + + public TokenExchangeSecretRetriever( + IHttpClientFactory? httpClientFactory = null, + IMemoryCache? cache = null, + IHttpContextAccessor? httpContextAccessor = null, + ISecretProviderRepository? secretRepo = null, + IAuthProviderRepository? authRepo = null, + IConfiguration? config = null, + ILogger? logger = null) + { + _httpClientFactory = httpClientFactory; + _cache = cache; + _httpContextAccessor = httpContextAccessor; + _secretRepo = secretRepo; + _authRepo = authRepo; + _config = config; + _logger = logger; + } + + public async Task GetSecretAsync(string secretPath, string keyName) + { + var options = await ResolveTokenExchangeOptionsAsync(secretPath, keyName); + + if (string.IsNullOrWhiteSpace(options.TokenEndpoint)) + { + throw new InvalidOperationException($"TokenExchange failed for path '{secretPath}': TokenEndpoint is not configured."); + } + + if (string.IsNullOrWhiteSpace(options.ClientId)) + { + throw new InvalidOperationException($"TokenExchange failed for path '{secretPath}': ClientId is not configured."); + } + + var subjectInfo = ResolveSubjectContext(); + var subjectToken = subjectInfo.SubjectToken; + var subject = subjectInfo.SubjectName; + + string scope = !string.IsNullOrWhiteSpace(keyName) ? keyName : (options.Scope ?? string.Empty); + string subjectKey = !string.IsNullOrWhiteSpace(subjectToken) ? subjectToken : (subject ?? "anonymous"); + string cacheKey = $"token_exchange:{options.ClientId}:{subjectKey}:{scope}:{options.TokenEndpoint}"; + + if (_cache != null && _cache.TryGetValue(cacheKey, out string? cachedToken) && !string.IsNullOrEmpty(cachedToken)) + { + _logger?.LogDebug("TokenExchange cache hit for subject '{Subject}' and scope '{Scope}'", subject, scope); + return cachedToken; + } + + var client = _httpClientFactory?.CreateClient("McpClient") ?? new HttpClient(); + + var formFields = new Dictionary + { + ["grant_type"] = !string.IsNullOrWhiteSpace(options.GrantType) ? options.GrantType : "urn:ietf:params:oauth:grant-type:token-exchange", + ["client_id"] = options.ClientId, + }; + + if (!string.IsNullOrWhiteSpace(options.ClientSecret)) + { + formFields["client_secret"] = options.ClientSecret; + } + + string grantType = formFields["grant_type"].ToLowerInvariant(); + + if (grantType == "urn:ietf:params:oauth:grant-type:jwt-bearer") + { + if (!string.IsNullOrWhiteSpace(subjectToken)) + { + formFields["assertion"] = subjectToken; + } + else if (!string.IsNullOrWhiteSpace(subject)) + { + formFields["assertion"] = subject; + } + formFields["requested_token_use"] = "on_behalf_of"; + } + else if (grantType == "urn:ietf:params:oauth:grant-type:token-exchange") + { + if (!string.IsNullOrWhiteSpace(subjectToken)) + { + formFields["subject_token"] = subjectToken; + formFields["subject_token_type"] = !string.IsNullOrWhiteSpace(options.SubjectTokenType) + ? options.SubjectTokenType + : "urn:ietf:params:oauth:token-type:access_token"; + } + else if (!string.IsNullOrWhiteSpace(subject)) + { + formFields["subject_token"] = subject; + formFields["subject_token_type"] = !string.IsNullOrWhiteSpace(options.SubjectTokenType) + ? options.SubjectTokenType + : "urn:ietf:params:oauth:token-type:access_token"; + } + + if (!string.IsNullOrWhiteSpace(options.RequestedTokenType)) + { + formFields["requested_token_type"] = options.RequestedTokenType; + } + } + + if (!string.IsNullOrWhiteSpace(scope)) + { + formFields["scope"] = scope; + } + else if (!string.IsNullOrWhiteSpace(options.Scope)) + { + formFields["scope"] = options.Scope; + } + + if (!string.IsNullOrWhiteSpace(options.Audience)) + { + formFields["audience"] = options.Audience; + } + + _logger?.LogInformation("Executing OAuth2 Token Exchange for subject '{Subject}' at endpoint '{Endpoint}'", subject, options.TokenEndpoint); + + using var request = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = new FormUrlEncodedContent(formFields) + }; + + var response = await client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger?.LogError("TokenExchange request failed with status {StatusCode}. Body: {Body}", response.StatusCode, responseBody); + throw new System.Security.SecurityException($"TokenExchange to '{options.TokenEndpoint}' failed with status {response.StatusCode}: {responseBody}"); + } + + using var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + string? accessToken = null; + if (root.TryGetProperty("access_token", out var tokenProp)) + { + accessToken = tokenProp.GetString(); + } + + if (string.IsNullOrEmpty(accessToken)) + { + throw new System.Security.SecurityException("TokenExchange response did not contain a valid 'access_token'."); + } + + int expiresIn = 3600; + if (root.TryGetProperty("expires_in", out var expProp)) + { + if (expProp.ValueKind == JsonValueKind.Number) + { + expiresIn = expProp.GetInt32(); + } + else if (expProp.ValueKind == JsonValueKind.String && int.TryParse(expProp.GetString(), out var parsedExp)) + { + expiresIn = parsedExp; + } + } + + if (_cache != null && expiresIn > 0) + { + var cacheTtl = TimeSpan.FromSeconds(Math.Max(30, expiresIn - 30)); + _cache.Set(cacheKey, accessToken, cacheTtl); + } + + return accessToken; + } + + private (string? SubjectToken, string? SubjectName) ResolveSubjectContext() + { + var httpContext = _httpContextAccessor?.HttpContext; + if (httpContext == null) + { + return (null, "system"); + } + + string? subjectToken = null; + if (httpContext.Request.Headers.TryGetValue("Authorization", out var authVals)) + { + var rawAuth = authVals.ToString(); + if (rawAuth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = rawAuth.Substring(7).Trim(); + if (!token.StartsWith("mcp-", StringComparison.OrdinalIgnoreCase)) + { + subjectToken = token; + } + } + } + + if (string.IsNullOrEmpty(subjectToken) && httpContext.Request.Headers.TryGetValue("X-Subject-Token", out var subjVals)) + { + subjectToken = subjVals.ToString().Trim(); + } + + if (string.IsNullOrEmpty(subjectToken) && httpContext.Request.Headers.TryGetValue("X-Target-Auth", out var targetAuthVals)) + { + subjectToken = targetAuthVals.ToString().Trim(); + } + + string? username = null; + if (httpContext.Items.TryGetValue("UserIdentityContext", out var ctxObj) && ctxObj is UserIdentityContext userCtx) + { + username = userCtx.Username; + } + else if (httpContext.User?.Identity?.IsAuthenticated == true) + { + username = httpContext.User.Identity.Name; + } + + return (subjectToken, username ?? "anonymous"); + } + + private async Task ResolveTokenExchangeOptionsAsync(string secretPath, string keyName) + { + var options = new TokenExchangeOptions(); + + if (_secretRepo != null) + { + try + { + var secretProviders = await _secretRepo.GetSecretProvidersAsync(); + var teProvider = secretProviders?.FindProvider("TokenExchange") + ?? secretProviders?.FindProvider("PocketID") + ?? secretProviders?.FindProvider("OIDC"); + + if (teProvider != null && teProvider.IsEnabled && !string.IsNullOrWhiteSpace(teProvider.ConfigJson)) + { + options.PopulateFromJson(teProvider.ConfigJson); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to load TokenExchange configuration from SecretProviders repository"); + } + } + + if (_authRepo != null && string.IsNullOrWhiteSpace(options.TokenEndpoint)) + { + try + { + var authProviders = await _authRepo.GetAuthProvidersAsync(); + var pocketIdAuth = authProviders?.FindAuthProvider("PocketID") + ?? authProviders?.FindAuthProvider("TokenExchange") + ?? authProviders?.FindAuthProvider("OIDC"); + + if (pocketIdAuth != null && pocketIdAuth.IsEnabled && !string.IsNullOrWhiteSpace(pocketIdAuth.ConfigJson)) + { + options.PopulateFromJson(pocketIdAuth.ConfigJson); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to load TokenExchange configuration from AuthProviderConfigs repository"); + } + } + + if (_config != null) + { + var section = _config.GetSection("Identity:TokenExchange"); + if (section.Exists()) + { + options.TokenEndpoint = options.TokenEndpoint ?? section["TokenEndpoint"]; + options.ClientId = options.ClientId ?? section["ClientId"]; + options.ClientSecret = options.ClientSecret ?? section["ClientSecret"]; + options.GrantType = options.GrantType ?? section["GrantType"]; + options.Scope = options.Scope ?? section["Scope"]; + options.Audience = options.Audience ?? section["Audience"]; + } + } + + if (!string.IsNullOrWhiteSpace(secretPath)) + { + if (secretPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + secretPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + options.TokenEndpoint = secretPath; + } + else if (secretPath.TrimStart().StartsWith("{")) + { + options.PopulateFromJson(secretPath); + } + } + + return options; + } + + private class TokenExchangeOptions + { + public string? TokenEndpoint { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? GrantType { get; set; } = "urn:ietf:params:oauth:grant-type:token-exchange"; + public string? SubjectTokenType { get; set; } = "urn:ietf:params:oauth:token-type:access_token"; + public string? RequestedTokenType { get; set; } = "urn:ietf:params:oauth:token-type:access_token"; + public string? Scope { get; set; } + public string? Audience { get; set; } + + public void PopulateFromJson(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("tokenEndpoint", out var te) || root.TryGetProperty("token_endpoint", out te) || root.TryGetProperty("url", out te)) + { + TokenEndpoint = te.GetString() ?? TokenEndpoint; + } + if (root.TryGetProperty("clientId", out var ci) || root.TryGetProperty("client_id", out ci)) + { + ClientId = ci.GetString() ?? ClientId; + } + if (root.TryGetProperty("clientSecret", out var cs) || root.TryGetProperty("client_secret", out cs)) + { + ClientSecret = cs.GetString() ?? ClientSecret; + } + if (root.TryGetProperty("grantType", out var gt) || root.TryGetProperty("grant_type", out gt)) + { + GrantType = gt.GetString() ?? GrantType; + } + if (root.TryGetProperty("scope", out var sc)) + { + Scope = sc.GetString() ?? Scope; + } + if (root.TryGetProperty("audience", out var aud)) + { + Audience = aud.GetString() ?? Audience; + } + if (root.TryGetProperty("subjectTokenType", out var stt) || root.TryGetProperty("subject_token_type", out stt)) + { + SubjectTokenType = stt.GetString() ?? SubjectTokenType; + } + if (root.TryGetProperty("requestedTokenType", out var rtt) || root.TryGetProperty("requested_token_type", out rtt)) + { + RequestedTokenType = rtt.GetString() ?? RequestedTokenType; + } + } + catch + { + // Ignore JSON parse errors + } + } + } + } + + internal static class TokenExchangeExtensionHelpers + { + public static SecretProviderDto? FindProvider(this IEnumerable list, string name) + { + if (list == null) return null; + foreach (var p in list) + { + if (string.Equals(p.ProviderName, name, StringComparison.OrdinalIgnoreCase)) + return p; + } + return null; + } + + public static AuthProviderDto? FindAuthProvider(this IEnumerable list, string name) + { + if (list == null) return null; + foreach (var p in list) + { + if (string.Equals(p.ProviderName, name, StringComparison.OrdinalIgnoreCase)) + return p; + } + return null; + } + } +} diff --git a/McpRouter.Tests/TokenExchangeSecretRetrieverTests.cs b/McpRouter.Tests/TokenExchangeSecretRetrieverTests.cs new file mode 100644 index 00000000..2d30f585 --- /dev/null +++ b/McpRouter.Tests/TokenExchangeSecretRetrieverTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using McpRouter.Components.Providers; +using McpRouter.Infrastructure.Identity; +using McpRouter.Infrastructure.Persistence; +using McpRouter.Infrastructure.Secrets; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using Moq.Protected; +using Xunit; + +namespace McpRouter.Tests +{ + public class TokenExchangeSecretRetrieverTests + { + private Mock CreateMockHttpClientFactory(HttpStatusCode statusCode, string responseJson) + { + var handlerMock = new Mock(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(handlerMock.Object); + var factoryMock = new Mock(); + factoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + return factoryMock; + } + + [Fact] + public async Task GetSecretAsync_MintsTokenViaTokenExchange_AndCachesResponse() + { + var tokenResponse = new + { + access_token = "eyJhbGciOiJSUzI1NiI.mock_downstream_jwt_token", + token_type = "Bearer", + expires_in = 3600 + }; + + var factoryMock = CreateMockHttpClientFactory(HttpStatusCode.OK, JsonSerializer.Serialize(tokenResponse)); + var cache = new MemoryCache(new MemoryCacheOptions()); + + var httpContext = new DefaultHttpContext(); + httpContext.Items["UserIdentityContext"] = new UserIdentityContext("alice", "AppKey", new List { "Users" }); + httpContext.Request.Headers["Authorization"] = "Bearer mcp-static-appkey-123"; + + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(h => h.HttpContext).Returns(httpContext); + + var secretRepoMock = new Mock(); + secretRepoMock.Setup(s => s.GetSecretProvidersAsync()).ReturnsAsync(new List + { + new SecretProviderDto + { + ProviderName = "TokenExchange", + IsEnabled = true, + ConfigJson = JsonSerializer.Serialize(new + { + tokenEndpoint = "https://pocketid.company.com/oauth/token", + clientId = "mcp-gateway-client", + clientSecret = "secret-key-123", + grantType = "urn:ietf:params:oauth:grant-type:token-exchange" + }) + } + }); + + var retriever = new TokenExchangeSecretRetriever( + factoryMock.Object, + cache, + httpContextAccessorMock.Object, + secretRepoMock.Object, + null, + null, + null + ); + + // First call -> hits HTTP endpoint + var token1 = await retriever.GetSecretAsync("", "mcp:write"); + Assert.Equal("eyJhbGciOiJSUzI1NiI.mock_downstream_jwt_token", token1); + + // Second call -> returns cached token + var token2 = await retriever.GetSecretAsync("", "mcp:write"); + Assert.Equal("eyJhbGciOiJSUzI1NiI.mock_downstream_jwt_token", token2); + } + + [Fact] + public async Task GetSecretAsync_ThrowsInvalidOperationException_WhenTokenEndpointMissing() + { + var retriever = new TokenExchangeSecretRetriever(); + await Assert.ThrowsAsync(() => retriever.GetSecretAsync("path", "key")); + } + + [Fact] + public async Task GetSecretAsync_ThrowsSecurityException_WhenHttpResponseIsNotSuccess() + { + var factoryMock = CreateMockHttpClientFactory(HttpStatusCode.BadRequest, "{\"error\":\"invalid_grant\"}"); + var configDict = new Dictionary + { + ["Identity:TokenExchange:TokenEndpoint"] = "https://identity.local/oauth/token", + ["Identity:TokenExchange:ClientId"] = "client123" + }; + var config = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var retriever = new TokenExchangeSecretRetriever( + factoryMock.Object, + null, + null, + null, + null, + config, + null + ); + + await Assert.ThrowsAsync(() => retriever.GetSecretAsync("", "")); + } + + [Fact] + public async Task CompositeSecretRetriever_RoutesOboAndPocketIdAliases_ToTokenExchangeRetriever() + { + var mockTe = new Mock(); + mockTe.Setup(r => r.ProviderName).Returns("TokenExchange"); + mockTe.Setup(r => r.GetSecretAsync("path", "scope")) + .ReturnsAsync("dynamic_jwt"); + + var composite = new CompositeSecretRetriever(new[] { mockTe.Object }); + + var r1 = await composite.GetSecretForProviderAsync("TokenExchange", "path", "scope"); + Assert.Equal("dynamic_jwt", r1); + + var r2 = await composite.GetSecretForProviderAsync("PocketID", "path", "scope"); + Assert.Equal("dynamic_jwt", r2); + + var r3 = await composite.GetSecretForProviderAsync("OBO", "path", "scope"); + Assert.Equal("dynamic_jwt", r3); + + var r4 = await composite.GetSecretForProviderAsync("OAuth2", "path", "scope"); + Assert.Equal("dynamic_jwt", r4); + } + } +} diff --git a/frontend/src/components/servers/ServerModal.tsx b/frontend/src/components/servers/ServerModal.tsx index 57a5f4b4..ecf45ac0 100644 --- a/frontend/src/components/servers/ServerModal.tsx +++ b/frontend/src/components/servers/ServerModal.tsx @@ -128,6 +128,7 @@ const ServerModalDialog: React.FC = () => { +
@@ -135,7 +136,13 @@ const ServerModalDialog: React.FC = () => { setSecretKey(e.target.value)} /> diff --git a/frontend/src/components/settings/SecretProvidersTab.tsx b/frontend/src/components/settings/SecretProvidersTab.tsx index 2450c56b..12a8d6e2 100644 --- a/frontend/src/components/settings/SecretProvidersTab.tsx +++ b/frontend/src/components/settings/SecretProvidersTab.tsx @@ -11,6 +11,7 @@ export const SecretProvidersTab: React.FC = ({ provider const vault = providers.find((p) => p.providerName === 'Vault'); const winreg = providers.find((p) => p.providerName === 'WindowsRegistry'); const env = providers.find((p) => p.providerName === 'Environment'); + const te = providers.find((p) => p.providerName === 'TokenExchange'); const parsedVault = vault?.configJson ? (() => { @@ -22,6 +23,16 @@ export const SecretProvidersTab: React.FC = ({ provider })() : {}; + const parsedTe = te?.configJson + ? (() => { + try { + return JSON.parse(te.configJson); + } catch { + return {}; + } + })() + : {}; + const parsedWinreg = winreg?.configJson ? (() => { try { @@ -64,6 +75,13 @@ export const SecretProvidersTab: React.FC = ({ provider const [secEnvEnabled, setSecEnvEnabled] = useState(env ? env.isEnabled : false); const [secEnvPrefix, setSecEnvPrefix] = useState(parsedEnv.prefix || ''); + const [secTeEnabled, setSecTeEnabled] = useState(te ? te.isEnabled : false); + const [secTeTokenEndpoint, setSecTeTokenEndpoint] = useState(parsedTe.tokenEndpoint || parsedTe.token_endpoint || ''); + const [secTeClientId, setSecTeClientId] = useState(parsedTe.clientId || parsedTe.client_id || ''); + const [secTeClientSecret, setSecTeClientSecret] = useState(parsedTe.clientSecret || parsedTe.client_secret || ''); + const [secTeGrantType, setSecTeGrantType] = useState(parsedTe.grantType || 'urn:ietf:params:oauth:grant-type:token-exchange'); + const [secTeScope, setSecTeScope] = useState(parsedTe.scope || ''); + const handleTestVault = async () => { setVaultTestStatus({ type: 'testing', message: 'Testing Vault connection...' }); try { @@ -110,6 +128,14 @@ export const SecretProvidersTab: React.FC = ({ provider prefix: secEnvPrefix, }; + const teConfig = { + tokenEndpoint: secTeTokenEndpoint, + clientId: secTeClientId, + clientSecret: secTeClientSecret, + grantType: secTeGrantType, + scope: secTeScope, + }; + await saveSecretProvider({ providerName: 'Vault', displayName: vault?.displayName || 'HashiCorp Vault (KV v2)', @@ -129,6 +155,12 @@ export const SecretProvidersTab: React.FC = ({ provider configJson: JSON.stringify(envConfig), isEnabled: secEnvEnabled, }); + await saveSecretProvider({ + providerName: 'TokenExchange', + displayName: te?.displayName || 'OAuth2 / OIDC Token Exchange (OBO)', + configJson: JSON.stringify(teConfig), + isEnabled: secTeEnabled, + }); alert('Secret Provider configurations saved successfully!'); } catch { alert('Failed to save Secret Providers'); @@ -145,7 +177,7 @@ export const SecretProvidersTab: React.FC = ({ provider Configure external vault and registry secret providers for resolving downstream MCP tokens.

-
+
{/* Vault */}
@@ -332,6 +364,69 @@ export const SecretProvidersTab: React.FC = ({ provider />
+ + {/* Token Exchange (OBO / PocketID) */} +
+
+

+ OAuth2 / OIDC Token Exchange (OBO / PocketID) +

+ +
+ + RFC 8693 Token Exchange / PocketID OIDC + +
+ setSecTeTokenEndpoint(e.target.value)} + style={{ fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', background: 'var(--bg-dark)', color: 'var(--text-main)' }} + /> + setSecTeClientId(e.target.value)} + style={{ fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', background: 'var(--bg-dark)', color: 'var(--text-main)' }} + /> + setSecTeClientSecret(e.target.value)} + style={{ fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', background: 'var(--bg-dark)', color: 'var(--text-main)' }} + /> + setSecTeGrantType(e.target.value)} + style={{ fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', background: 'var(--bg-dark)', color: 'var(--text-main)' }} + /> + setSecTeScope(e.target.value)} + style={{ fontSize: '11px', padding: '4px 8px', border: '1px solid var(--border-color)', background: 'var(--bg-dark)', color: 'var(--text-main)', gridColumn: 'span 2' }} + /> +
+