feat: Add Enterprise Managed Authorization (SEP-990) support (#1305)

This commit is contained in:
Aniket Anil Kumar 2026-06-03 20:08:08 +05:30 • committed by GitHub
commit 8202bcc932
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1695 additions and 2 deletions

View file

@ -0,0 +1,167 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using System.Net.Http.Headers;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
/// <summary>
/// Integration tests for Cross-Application Access authorization using the in-memory
/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and
/// the MCP Authorization Server (AS).
///
/// Flow exercised:
/// 1. <see cref="IdentityAssertionGrantProvider.GetAccessTokenAsync"/> discovers the MCP AS
/// metadata and calls the ID token callback.
/// 2. The provider performs RFC 8693 token exchange at <c>/idp/token</c> on the test OAuth server
/// (ID token → JAG).
/// 3. The provider exchanges the JAG for an access token at <c>/token</c>
/// (RFC 7523 JWT-bearer grant: JAG → access token).
/// 4. The access token is passed to the MCP client transport and used to authenticate
/// against the protected MCP server.
/// </summary>
public class IdentityAssertionGrantIntegrationTests : OAuthTestBase
{
public IdentityAssertionGrantIntegrationTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}
[Fact]
public async Task CanAuthenticate_WithIdentityAssertionGrantProvider()
{
// Enable Enterprise Managed Authorization endpoints on the test OAuth server.
TestOAuthServer.EnterpriseSupportEnabled = true;
await using var app = await StartMcpServerAsync();
// Simulate the enterprise ID token that would normally come from the SSO login step.
const string simulatedIdToken = "test-enterprise-sso-id-token";
// Create the provider with IdP config folded into options.
// The ID token callback just returns the SSO ID token; the provider performs
// RFC 8693 (ID token → JAG) and RFC 7523 (JAG → access token) internally.
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "enterprise-mcp-client",
ClientSecret = "enterprise-mcp-secret",
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
IdpClientId = "enterprise-idp-client",
IdpClientSecret = "enterprise-idp-secret",
IdTokenCallback = (_, ct) => Task.FromResult(simulatedIdToken),
},
httpClient: HttpClient);
// Run the full Cross-Application Access flow: discover AS → get JAG → exchange for access token.
var tokens = await provider.GetAccessTokenAsync(
resourceUrl: new Uri(McpServerUrl),
authorizationServerUrl: new Uri(OAuthServerUrl),
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tokens.AccessToken);
Assert.False(string.IsNullOrEmpty(tokens.AccessToken));
Assert.Equal("bearer", tokens.TokenType, ignoreCase: true);
// Wire the obtained access token into an HTTP client that shares the same
// in-memory Kestrel transport as the rest of the test fixture.
var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false);
ConfigureHttpClient(mcpHttpClient);
mcpHttpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
// Connect the MCP client using the enterprise access token — no interactive OAuth flow.
await using var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) },
mcpHttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
// If we get here the MCP server accepted the enterprise access token.
Assert.NotNull(client);
}
[Fact]
public async Task IdentityAssertionGrantProvider_ReturnsCachedToken_OnSecondCall()
{
TestOAuthServer.EnterpriseSupportEnabled = true;
await using var _ = await StartMcpServerAsync();
var idTokenCallCount = 0;
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "enterprise-mcp-client",
ClientSecret = "enterprise-mcp-secret",
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
IdpClientId = "enterprise-idp-client",
IdpClientSecret = "enterprise-idp-secret",
IdTokenCallback = (_, ct) =>
{
idTokenCallCount++;
return Task.FromResult("test-sso-token");
},
},
httpClient: HttpClient);
var tokens1 = await provider.GetAccessTokenAsync(
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
TestContext.Current.CancellationToken);
var tokens2 = await provider.GetAccessTokenAsync(
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
TestContext.Current.CancellationToken);
// The ID token callback (and therefore the IdP round-trip) should only fire once.
Assert.Equal(1, idTokenCallCount);
Assert.Equal(tokens1.AccessToken, tokens2.AccessToken);
}
[Fact]
public async Task IdentityAssertionGrantProvider_FetchesFreshToken_AfterInvalidateCache()
{
TestOAuthServer.EnterpriseSupportEnabled = true;
await using var _ = await StartMcpServerAsync();
var idTokenCallCount2 = 0;
var provider2 = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "enterprise-mcp-client",
ClientSecret = "enterprise-mcp-secret",
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
IdpClientId = "enterprise-idp-client",
IdpClientSecret = "enterprise-idp-secret",
IdTokenCallback = (_, ct) =>
{
idTokenCallCount2++;
return Task.FromResult("test-sso-token");
},
},
httpClient: HttpClient);
var tokens1 = await provider2.GetAccessTokenAsync(
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
TestContext.Current.CancellationToken);
// Invalidate the cache to force a full re-exchange.
provider2.InvalidateCache();
var tokens2 = await provider2.GetAccessTokenAsync(
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
TestContext.Current.CancellationToken);
// The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation.
Assert.Equal(2, idTokenCallCount2);
// The tokens may or may not be identical depending on timing, but the flow ran again.
Assert.NotNull(tokens2.AccessToken);
}
}

View file

@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG)
/// per RFC 8693 / SEP-990.
/// </summary>
internal sealed class JagTokenExchangeResponse
{
/// <summary>
/// Gets or sets the issued JWT Authorization Grant (JAG).
/// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT,
/// not an OAuth access token.
/// </summary>
[JsonPropertyName("access_token")]
public required string AccessToken { get; init; }
/// <summary>
/// Gets or sets the type of security token issued.
/// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag".
/// </summary>
[JsonPropertyName("issued_token_type")]
public required string IssuedTokenType { get; init; }
/// <summary>
/// Gets or sets the token type.
/// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token.
/// </summary>
[JsonPropertyName("token_type")]
public required string TokenType { get; init; }
/// <summary>
/// Gets or sets the lifetime in seconds of the issued JAG.
/// </summary>
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; init; }
}

View file

@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer;
[JsonSerializable(typeof(OAuthServerMetadata))]
[JsonSerializable(typeof(AuthorizationServerMetadata))]
[JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(JagTokenExchangeResponse))]
[JsonSerializable(typeof(JsonWebKeySet))]
[JsonSerializable(typeof(JsonWebKey))]
[JsonSerializable(typeof(TokenIntrospectionResponse))]

View file

@ -57,6 +57,18 @@ public sealed class Program
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
public bool HasRefreshedToken { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the server supports the Enterprise Managed
/// Authorization (SEP-990) flow, including the IdP token-exchange endpoint and the
/// JWT-bearer grant type at the token endpoint.
/// </summary>
/// <remarks>
/// When <c>true</c>, the server registers enterprise test clients and activates the
/// <c>/idp/token</c> endpoint (RFC 8693 token exchange) and the
/// <c>urn:ietf:params:oauth:grant-type:jwt-bearer</c> grant type (RFC 7523).
/// </remarks>
public bool EnterpriseSupportEnabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the authorization server
/// advertises support for client ID metadata documents in its discovery
@ -171,6 +183,25 @@ public sealed class Program
RedirectUris = ["http://localhost:1179/callback"],
};
// Enterprise Auth (SEP-990) clients.
// The IdP client is used to authenticate calls to /idp/token (token exchange).
// The MCP client is used to authenticate calls to /token (jwt-bearer grant).
// Neither needs redirect URIs because neither uses the authorization code flow.
_clients["enterprise-idp-client"] = new ClientInfo
{
ClientId = "enterprise-idp-client",
ClientSecret = "enterprise-idp-secret",
RequiresClientSecret = true,
RedirectUris = [],
};
_clients["enterprise-mcp-client"] = new ClientInfo
{
ClientId = "enterprise-mcp-client",
ClientSecret = "enterprise-mcp-secret",
RequiresClientSecret = true,
RedirectUris = [],
};
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
// /.well-known/openid-configuration by default.
//
@ -363,10 +394,18 @@ public sealed class Program
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
}
// Read grant type early so we can skip resource validation for grant types that
// don't use the resource parameter (e.g. jwt-bearer where the resource is embedded
// inside the JWT assertion itself).
var grant_type = form["grant_type"].ToString();
// Validate resource in accordance with RFC 8707.
// When ExpectResource is false, the resource parameter must be absent (legacy mode).
// RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself,
// so we skip the form-level resource check for that grant type.
var resource = form["resource"].ToString();
if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
(ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)))
{
return Results.BadRequest(new OAuthErrorResponse
{
@ -375,7 +414,6 @@ public sealed class Program
});
}
var grant_type = form["grant_type"].ToString();
if (grant_type == "authorization_code")
{
var code = form["code"].ToString();
@ -452,6 +490,45 @@ public sealed class Program
HasRefreshedToken = true;
return Results.Ok(response);
}
else if (grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer")
{
if (!EnterpriseSupportEnabled)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "unsupported_grant_type",
ErrorDescription = "JWT bearer grant is not enabled on this server."
});
}
var assertion = form["assertion"].ToString();
if (string.IsNullOrEmpty(assertion))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "assertion is required for jwt-bearer grant"
});
}
// Extract the target resource from the JAG payload (set during /idp/token).
// Fall back to ValidResources[0] so the token is still usable in tests even
// if the resource claim is absent.
var jagResource = ExtractJwtClaim(assertion, "resource");
if (string.IsNullOrEmpty(jagResource) || !ValidResources.Contains(jagResource))
{
jagResource = ValidResources.Length > 0 ? ValidResources[0] : null;
}
var resourceUri = jagResource is not null ? new Uri(jagResource) : null;
var scope = form["scope"].ToString();
var scopes = string.IsNullOrEmpty(scope)
? ["mcp:tools"]
: scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
var response = GenerateJwtTokenResponse(client.ClientId, scopes, resourceUri);
return Results.Ok(response);
}
else
{
return Results.BadRequest(new OAuthErrorResponse
@ -462,6 +539,77 @@ public sealed class Program
}
});
// IdP token-exchange endpoint (RFC 8693) for Enterprise Managed Authorization (SEP-990).
// Exchanges an enterprise ID token (from SSO) for a JWT Authorization Grant (JAG)
// that can subsequently be used at the /token endpoint via the jwt-bearer grant.
app.MapPost("/idp/token", async (HttpContext context) =>
{
if (!EnterpriseSupportEnabled)
{
return Results.NotFound();
}
var form = await context.Request.ReadFormAsync();
// Authenticate the IdP client.
var client = AuthenticateClient(context, form);
if (client == null)
{
context.Response.StatusCode = 401;
return Results.Problem(
statusCode: 401,
title: "Unauthorized",
detail: "Invalid client credentials",
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
}
var grantType = form["grant_type"].ToString();
if (grantType != "urn:ietf:params:oauth:grant-type:token-exchange")
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "unsupported_grant_type",
ErrorDescription = "Only urn:ietf:params:oauth:grant-type:token-exchange is supported on this endpoint."
});
}
var subjectToken = form["subject_token"].ToString();
if (string.IsNullOrEmpty(subjectToken))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "subject_token is required."
});
}
var requestedTokenType = form["requested_token_type"].ToString();
if (requestedTokenType != "urn:ietf:params:oauth:token-type:id-jag")
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "requested_token_type must be urn:ietf:params:oauth:token-type:id-jag."
});
}
var audience = form["audience"].ToString();
var resourceParam = form["resource"].ToString();
// Generate a JAG JWT signed with the server's RSA key.
// The JAG encodes the intended audience (MCP AS) and resource (MCP server) so
// the /token endpoint can later issue a correctly-scoped access token.
var jag = GenerateJagJwt(audience, resourceParam);
return Results.Ok(new JagTokenExchangeResponse
{
AccessToken = jag,
IssuedTokenType = "urn:ietf:params:oauth:token-type:id-jag",
TokenType = "N_A",
ExpiresIn = 300,
});
});
// Introspection endpoint
app.MapPost("/introspect", async (HttpContext context) =>
{
@ -692,6 +840,70 @@ public sealed class Program
};
}
/// <summary>
/// Generates a JWT Authorization Grant (JAG) signed with the server's RSA key.
/// The JAG encodes the target audience (MCP AS URL) and the resource (MCP server URL).
/// </summary>
private string GenerateJagJwt(string audience, string resource)
{
var expiresIn = TimeSpan.FromMinutes(5);
var issuedAt = DateTimeOffset.UtcNow;
var expiresAt = issuedAt.Add(expiresIn);
var header = new Dictionary<string, string>
{
{ "alg", "RS256" },
{ "typ", "JWT" },
{ "kid", _keyId },
};
var payload = new Dictionary<string, string>
{
{ "iss", _url },
{ "sub", "enterprise-user" },
{ "aud", audience },
{ "resource", resource }, // carried through so /token can issue the right audience
{ "jti", Guid.NewGuid().ToString() },
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
};
var headerJson = System.Text.Json.JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString);
var payloadJson = System.Text.Json.JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString);
var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
var dataToSign = $"{headerBase64}.{payloadBase64}";
var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return $"{headerBase64}.{payloadBase64}.{WebEncoders.Base64UrlEncode(signature)}";
}
/// <summary>
/// Decodes a JWT payload (without signature verification) and returns the value of
/// <paramref name="claimName"/>, or <c>null</c> if the claim is absent or the JWT is malformed.
/// </summary>
private static string? ExtractJwtClaim(string jwt, string claimName)
{
var parts = jwt.Split('.');
if (parts.Length < 2)
{
return null;
}
try
{
var payloadJson = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(parts[1]));
var payload = System.Text.Json.JsonSerializer.Deserialize(payloadJson, OAuthJsonContext.Default.DictionaryStringString);
return payload?.TryGetValue(claimName, out var value) == true ? value : null;
}
catch
{
return null;
}
}
/// <summary>
/// Generates a random token for authorization code or refresh token.
/// </summary>

View file

@ -0,0 +1,389 @@
using System.Net;
using System.Text.Json.Nodes;
using ModelContextProtocol.Authentication;
namespace ModelContextProtocol.Tests;
public sealed class IdentityAssertionGrantTests : IDisposable
{
private readonly MockHttpMessageHandler _mockHandler;
private readonly HttpClient _httpClient;
public IdentityAssertionGrantTests()
{
_mockHandler = new MockHttpMessageHandler();
_httpClient = new HttpClient(_mockHandler);
}
public void Dispose()
{
_httpClient.Dispose();
_mockHandler.Dispose();
}
#region IdentityAssertionGrantProvider Tests
[Fact]
public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken()
{
_mockHandler.Handler = request =>
{
var url = request.RequestUri!.ToString();
if (url.Contains(".well-known/openid-configuration"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["issuer"] = "https://auth.mcp-server.example.com",
["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize",
["token_endpoint"] = "https://auth.mcp-server.example.com/token",
});
}
if (url.Contains("idp.example.com/token"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "mock-jag-assertion",
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
["token_type"] = "N_A",
});
}
if (url.Contains("auth.mcp-server.example.com/token"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "final-access-token",
["token_type"] = "Bearer",
["expires_in"] = 3600,
});
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
};
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "mcp-client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (context, ct) =>
{
Assert.Equal(new Uri("https://mcp-server.example.com"), context.ResourceUrl);
Assert.Equal(new Uri("https://auth.mcp-server.example.com"), context.AuthorizationServerUrl);
return Task.FromResult("mock-id-token");
},
},
_httpClient);
var tokens = await provider.GetAccessTokenAsync(
resourceUrl: new Uri("https://mcp-server.example.com"),
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
TestContext.Current.CancellationToken);
Assert.Equal("final-access-token", tokens.AccessToken);
Assert.Equal("Bearer", tokens.TokenType);
Assert.Equal(3600, tokens.ExpiresIn);
}
[Fact]
public async Task IdentityAssertionGrantProvider_CachesTokens()
{
var mcpTokenCallCount = 0;
_mockHandler.Handler = request =>
{
var url = request.RequestUri!.ToString();
if (url.Contains(".well-known"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["authorization_endpoint"] = "https://auth.example.com/authorize",
["token_endpoint"] = "https://auth.example.com/token",
});
}
if (url.Contains("idp.example.com"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "mock-jag",
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
["token_type"] = "N_A",
});
}
mcpTokenCallCount++;
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "cached-token",
["token_type"] = "Bearer",
["expires_in"] = 3600,
});
};
var idTokenCallCount = 0;
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) =>
{
idTokenCallCount++;
return Task.FromResult("mock-id-token");
},
},
_httpClient);
var ct = TestContext.Current.CancellationToken;
var firstTokens = await provider.GetAccessTokenAsync(
new Uri("https://resource.example.com"),
new Uri("https://auth.example.com"), ct);
var secondTokens = await provider.GetAccessTokenAsync(
new Uri("https://resource.example.com"),
new Uri("https://auth.example.com"), ct);
Assert.Same(firstTokens, secondTokens);
Assert.Equal(1, idTokenCallCount);
Assert.Equal(1, mcpTokenCallCount);
}
[Fact]
public async Task IdentityAssertionGrantProvider_InvalidateCache_ForcesRefresh()
{
var idTokenCallCount = 0;
_mockHandler.Handler = request =>
{
var url = request.RequestUri!.ToString();
if (url.Contains(".well-known"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["authorization_endpoint"] = "https://auth.example.com/authorize",
["token_endpoint"] = "https://auth.example.com/token",
});
}
if (url.Contains("idp.example.com"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = "mock-jag",
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
["token_type"] = "N_A",
});
}
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["access_token"] = $"token-{idTokenCallCount}",
["token_type"] = "Bearer",
["expires_in"] = 3600,
});
};
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) =>
{
idTokenCallCount++;
return Task.FromResult("mock-id-token");
},
},
_httpClient);
var ct = TestContext.Current.CancellationToken;
await provider.GetAccessTokenAsync(
new Uri("https://resource.example.com"),
new Uri("https://auth.example.com"), ct);
provider.InvalidateCache();
await provider.GetAccessTokenAsync(
new Uri("https://resource.example.com"),
new Uri("https://auth.example.com"), ct);
Assert.Equal(2, idTokenCallCount);
}
[Fact]
public async Task IdentityAssertionGrantProvider_IdTokenCallbackReturnsEmpty_ThrowsException()
{
_mockHandler.Handler = request =>
{
var url = request.RequestUri!.ToString();
if (url.Contains(".well-known"))
{
return JsonResponse(HttpStatusCode.OK, new JsonObject
{
["authorization_endpoint"] = "https://auth.example.com/authorize",
["token_endpoint"] = "https://auth.example.com/token",
});
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
};
var provider = new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) => Task.FromResult(string.Empty),
},
_httpClient);
await Assert.ThrowsAsync<IdentityAssertionGrantException>(
() => provider.GetAccessTokenAsync(
new Uri("https://resource.example.com"),
new Uri("https://auth.example.com"),
TestContext.Current.CancellationToken));
}
[Fact]
public void IdentityAssertionGrantProvider_NullOptions_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(null!, _httpClient));
}
[Fact]
public void IdentityAssertionGrantProvider_NullHttpClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) => Task.FromResult("token"),
},
null!));
}
[Fact]
public void IdentityAssertionGrantProvider_MissingClientId_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) => Task.FromResult("test"),
},
_httpClient));
}
[Fact]
public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = null!,
},
_httpClient));
}
[Fact]
public void IdentityAssertionGrantProvider_MissingIdpConfig_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpClientId = "idp-client-id",
// Neither IdpUrl nor IdpTokenEndpoint provided
IdTokenCallback = (_, _) => Task.FromResult("test"),
},
_httpClient));
}
#endregion
#region IdentityAssertionGrantException Tests
[Fact]
public void IdentityAssertionGrantException_WithErrorCodeAndDescription_FormatsMessage()
{
var ex = new IdentityAssertionGrantException("Base message", "invalid_grant", "Token expired");
Assert.Contains("Base message", ex.Message);
Assert.Contains("invalid_grant", ex.Message);
Assert.Contains("Token expired", ex.Message);
Assert.Equal("invalid_grant", ex.ErrorCode);
Assert.Equal("Token expired", ex.ErrorDescription);
}
[Fact]
public void IdentityAssertionGrantException_WithErrorUri_StoresIt()
{
var ex = new IdentityAssertionGrantException("msg", "error", "desc", "https://docs.example.com/error");
Assert.Equal("https://docs.example.com/error", ex.ErrorUri);
}
[Fact]
public void IdentityAssertionGrantException_WithoutErrorDetails_PlainMessage()
{
var ex = new IdentityAssertionGrantException("Simple error");
Assert.Equal("Simple error", ex.Message);
Assert.Null(ex.ErrorCode);
Assert.Null(ex.ErrorDescription);
Assert.Null(ex.ErrorUri);
}
#endregion
#region Helpers
private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload)
{
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(payload.ToJsonString(), System.Text.Encoding.UTF8, "application/json")
};
}
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
public Func<HttpRequestMessage, HttpResponseMessage>? Handler { get; set; }
public Func<HttpRequestMessage, Task<HttpResponseMessage>>? AsyncHandler { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (AsyncHandler is not null)
{
return await AsyncHandler(request);
}
if (Handler is not null)
{
return Handler(request);
}
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("No mock response configured")
};
}
}
#endregion
}