using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using System.Collections.Concurrent; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace ModelContextProtocol.TestOAuthServer; public sealed class Program { private const int _port = 7029; private static readonly string _url = $"https://localhost:{_port}"; private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json"; // Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample // Per MCP spec, URIs should not have trailing slashes unless semantically significant public string[] ValidResources { get; set; } = [ "http://localhost:5000", "http://localhost:5000/mcp", "http://localhost:7071" ]; private readonly ConcurrentDictionary _authCodes = new(); private readonly ConcurrentDictionary _tokens = new(); private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentQueue _metadataRequests = new(); private int _authorizationCodeTokenRequestCount; private readonly RSA _rsa; private readonly string _keyId; private readonly ILoggerProvider? _loggerProvider; private readonly IConnectionListenerFactory? _kestrelTransport; private readonly TaskCompletionSource _serverStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Initializes a new instance of the class with logging and transport parameters. /// /// Optional logger provider for logging. /// Optional Kestrel transport for in-memory connections. public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null) { _rsa = RSA.Create(2048); _keyId = Guid.NewGuid().ToString(); _loggerProvider = loggerProvider; _kestrelTransport = kestrelTransport; } /// /// Gets a task that completes when the server has started and is ready to accept connections. /// public Task ServerStarted => _serverStarted.Task; // 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; } /// /// 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. /// /// /// When true, the server registers enterprise test clients and activates the /// /idp/token endpoint (RFC 8693 token exchange) and the /// urn:ietf:params:oauth:grant-type:jwt-bearer grant type (RFC 7523). /// public bool EnterpriseSupportEnabled { get; set; } /// /// Gets or sets a value indicating whether the authorization server /// advertises support for client ID metadata documents in its discovery /// document. This is used by tests to toggle CIMD support. /// /// /// The default value is true. /// public bool ClientIdMetadataDocumentSupported { get; set; } = true; /// /// Gets or sets a value indicating whether the authorization server expects a resource parameter. /// When true, the resource parameter must be present and match a valid resource. /// When false, the resource parameter must be absent to simulate legacy servers that /// do not support RFC 8707 resource indicators. /// /// /// The default value is true. /// public bool ExpectResource { get; set; } = true; /// /// Gets or sets a value indicating whether the authorization server advertises support for /// offline_access in its scopes_supported metadata. This simulates an OIDC-flavored /// authorization server that issues refresh tokens when the client requests the offline_access scope. /// /// /// The default value is false. /// public bool IncludeOfflineAccessInMetadata { get; set; } /// /// Gets or sets a value indicating whether authorization server metadata includes an issuer. /// public bool IncludeIssuerInMetadata { get; set; } = true; /// /// Gets or sets an issuer value that overrides the authorization server's metadata issuer. /// public string? MetadataIssuerOverride { get; set; } /// /// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support. /// public bool AuthorizationResponseIssParameterSupported { get; set; } /// /// Gets or sets the issuer included in authorization responses, or to omit it. /// public string? AuthorizationResponseIssuer { get; set; } /// /// Gets or sets the code challenge methods advertised by metadata endpoints. /// /// /// The default value is ["S256"]. /// public List? CodeChallengeMethodsSupported { get; set; } = ["S256"]; /// /// Gets the set of metadata paths that should omit code_challenge_methods_supported from their /// response, simulating a server whose discovery endpoints advertise differing PKCE support. /// public HashSet MetadataPathsWithoutPkceSupport { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); /// Gets the number of authorization-code token exchange requests received. public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount); /// Gets the scope field from the most recent Dynamic Client Registration request. public string? LastRegistrationScope { get; private set; } /// Gets the application_type field from the most recent Dynamic Client Registration request. public string? LastApplicationType { get; private set; } /// /// Entry point for the application. /// /// Command line arguments. /// A task representing the asynchronous operation. public static Task Main(string[] args) => new Program().RunServerAsync(args); /// /// Runs the OAuth server with the specified parameters. /// /// Command line arguments. /// Cancellation token to stop the server. /// A task representing the asynchronous operation. public async Task RunServerAsync(string[]? args = null, CancellationToken cancellationToken = default) { Console.WriteLine("Starting in-memory test-only OAuth Server..."); var builder = WebApplication.CreateEmptyBuilder(new() { Args = args, }); if (_kestrelTransport is not null) { // Add passed-in transport before calling UseKestrel() to avoid the SocketsHttpHandler getting added. builder.Services.AddSingleton(_kestrelTransport); } builder.WebHost.UseKestrel(kestrelOptions => { kestrelOptions.ListenLocalhost(_port, listenOptions => { listenOptions.UseHttps(); }); }); builder.Services.AddRoutingCore(); builder.Services.AddLogging(); builder.Services.ConfigureHttpJsonOptions(jsonOptions => { jsonOptions.SerializerOptions.TypeInfoResolverChain.Add(OAuthJsonContext.Default); }); builder.Logging.AddConsole(); if (_loggerProvider is not null) { builder.Logging.AddProvider(_loggerProvider); } var app = builder.Build(); var clientId = "demo-client"; var clientSecret = "demo-secret"; _clients[clientId] = new ClientInfo { ClientId = clientId, ClientSecret = clientSecret, RequiresClientSecret = true, RedirectUris = ["http://localhost:1179/callback"], }; // This client is pre-registered to support testing Client ID Metadata Documents (CIMD). // A non-test OAuth server implementation would fetch the metadata document from the client-specified // URL during authorization, but we just register the client here to keep the test implementation simple. // We also set 'RequiresClientSecret' to 'false' here because client secrets are disallowed when using CIMD. // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-4.1 _clients[_clientMetadataDocumentUrl] = new ClientInfo { ClientId = _clientMetadataDocumentUrl, RequiresClientSecret = false, 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. // // The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata respectively. // They do differ, but it's close enough at least for our current testing to use the same response for both. // See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) { _metadataRequests.Enqueue(context.Request.Path); if (DisabledMetadataPaths.Contains(context.Request.Path)) { return Results.NotFound(); } if (!string.IsNullOrEmpty(issuerPath)) { issuerPath = $"/{issuerPath}"; } var metadata = new OAuthServerMetadata { Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null, AuthorizationEndpoint = $"{_url}/authorize", TokenEndpoint = $"{_url}/token", JwksUri = $"{_url}/.well-known/jwks.json", ResponseTypesSupported = ["code"], SubjectTypesSupported = ["public"], IdTokenSigningAlgValuesSupported = ["RS256"], ScopesSupported = IncludeOfflineAccessInMetadata ? ["openid", "profile", "email", "mcp:tools", "offline_access"] : ["openid", "profile", "email", "mcp:tools"], TokenEndpointAuthMethodsSupported = ["client_secret_post"], ClaimsSupported = ["sub", "iss", "name", "email", "aud"], CodeChallengeMethodsSupported = MetadataPathsWithoutPkceSupport.Contains(context.Request.Path) ? null : CodeChallengeMethodsSupported, GrantTypesSupported = ["authorization_code", "refresh_token"], IntrospectionEndpoint = $"{_url}/introspect", RegistrationEndpoint = $"{_url}/register", ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null, }; return Results.Ok(metadata); } app.MapGet("/.well-known/oauth-authorization-server", HandleMetadataRequest); app.MapGet("/.well-known/openid-configuration", HandleMetadataRequest); app.MapGet("/.well-known/oauth-authorization-server/{**issuerPath}", HandleMetadataRequest); app.MapGet("/.well-known/openid-configuration/{**issuerPath}", HandleMetadataRequest); app.MapGet("/{**fullPath}", (HttpContext context, string fullPath) => { if (fullPath.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase)) { return HandleMetadataRequest(context, fullPath[..^"/.well-known/openid-configuration".Length]); } return Results.NotFound(); }); // JWKS endpoint to expose the public key app.MapGet("/.well-known/jwks.json", () => { var parameters = _rsa.ExportParameters(false); // Convert parameters to base64url encoding var e = WebEncoders.Base64UrlEncode(parameters.Exponent ?? Array.Empty()); var n = WebEncoders.Base64UrlEncode(parameters.Modulus ?? Array.Empty()); var jwks = new JsonWebKeySet { Keys = [ new JsonWebKey { KeyType = "RSA", Use = "sig", KeyId = _keyId, Algorithm = "RS256", Exponent = e, Modulus = n } ] }; return Results.Ok(jwks); }); // Authorize endpoint app.MapGet("/authorize", ( [FromQuery] string client_id, [FromQuery] string? redirect_uri, [FromQuery] string response_type, [FromQuery] string code_challenge, [FromQuery] string code_challenge_method, [FromQuery] string? scope, [FromQuery] string? state, [FromQuery] string? resource) => { // Validate client if (!_clients.TryGetValue(client_id, out var client)) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_client", ErrorDescription = "Client not found" }); } // Validate redirect_uri if (string.IsNullOrEmpty(redirect_uri)) { if (client.RedirectUris.Count == 1) { redirect_uri = client.RedirectUris[0]; } else { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_request", ErrorDescription = "redirect_uri is required when client has multiple registered URIs" }); } } else if (!client.RedirectUris.Contains(redirect_uri)) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_request", ErrorDescription = "Unregistered redirect_uri" }); } // Validate response_type if (response_type != "code") { return Results.Redirect($"{redirect_uri}?error=unsupported_response_type&error_description=Only+code+response_type+is+supported&state={state}"); } // Validate code challenge method if (code_challenge_method != "S256") { return Results.Redirect($"{redirect_uri}?error=invalid_request&error_description=Only+S256+code_challenge_method+is+supported&state={state}"); } // Validate resource in accordance with RFC 8707. // When ExpectResource is false, the resource parameter must be absent (legacy mode). if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) { return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}"); } // Generate a new authorization code var code = GenerateRandomToken(); var requestedScopes = scope?.Split(' ').ToList() ?? []; // Store code information for later verification _authCodes[code] = new AuthorizationCodeInfo { ClientId = client_id, RedirectUri = redirect_uri, CodeChallenge = code_challenge, Scope = requestedScopes, Resource = !string.IsNullOrEmpty(resource) ? new Uri(resource) : null }; // Redirect back to client with the code var redirectUrl = $"{redirect_uri}?code={code}"; if (!string.IsNullOrEmpty(state)) { redirectUrl += $"&state={Uri.EscapeDataString(state)}"; } if (!string.IsNullOrEmpty(AuthorizationResponseIssuer)) { redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}"; } return Results.Redirect(redirectUrl); }); // Token endpoint app.MapPost("/token", async (HttpContext context) => { var form = await context.Request.ReadFormAsync(); // Authenticate 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"); } // 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 (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 { Error = "invalid_target", ErrorDescription = "The specified resource is not valid." }); } if (grant_type == "authorization_code") { Interlocked.Increment(ref _authorizationCodeTokenRequestCount); var code = form["code"].ToString(); var code_verifier = form["code_verifier"].ToString(); var redirect_uri = form["redirect_uri"].ToString(); // Validate code if (string.IsNullOrEmpty(code) || !_authCodes.TryRemove(code, out var codeInfo)) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid authorization code" }); } // Validate client_id if (codeInfo.ClientId != client.ClientId) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_grant", ErrorDescription = "Authorization code was not issued to this client" }); } // Validate redirect_uri if provided if (!string.IsNullOrEmpty(redirect_uri) && redirect_uri != codeInfo.RedirectUri) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_grant", ErrorDescription = "Redirect URI mismatch" }); } // Validate code verifier if (string.IsNullOrEmpty(code_verifier) || !VerifyCodeChallenge(code_verifier, codeInfo.CodeChallenge)) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_grant", ErrorDescription = "Code verifier does not match the challenge" }); } // Generate JWT token response var response = GenerateJwtTokenResponse(client.ClientId, codeInfo.Scope, codeInfo.Resource); return Results.Ok(response); } else if (grant_type == "refresh_token") { var refresh_token = form["refresh_token"].ToString(); // Validate refresh token if (string.IsNullOrEmpty(refresh_token) || !_tokens.TryGetValue(refresh_token, out var tokenInfo) || tokenInfo.ClientId != client.ClientId) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid refresh token" }); } // Generate new token response, keeping the same scopes var response = GenerateJwtTokenResponse(client.ClientId, tokenInfo.Scopes, tokenInfo.Resource); // Remove the old refresh token if (!string.IsNullOrEmpty(refresh_token)) { _tokens.TryRemove(refresh_token, out _); } 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 { Error = "unsupported_grant_type", ErrorDescription = "Unsupported grant type" }); } }); // 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) => { var form = await context.Request.ReadFormAsync(); var token = form["token"].ToString(); if (string.IsNullOrEmpty(token)) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_request", ErrorDescription = "Token is required" }); } // Check opaque access tokens if (_tokens.TryGetValue(token, out var tokenInfo)) { if (tokenInfo.ExpiresAt < DateTimeOffset.UtcNow) { return Results.Ok(new TokenIntrospectionResponse { Active = false }); } return Results.Ok(new TokenIntrospectionResponse { Active = true, ClientId = tokenInfo.ClientId, Scope = string.Join(" ", tokenInfo.Scopes), ExpirationTime = tokenInfo.ExpiresAt.ToUnixTimeSeconds(), Audience = tokenInfo.Resource?.ToString() }); } return Results.Ok(new TokenIntrospectionResponse { Active = false }); }); // Dynamic Client Registration endpoint (RFC 7591) app.MapPost("/register", async (HttpContext context) => { using var stream = context.Request.Body; var registrationRequest = await JsonSerializer.DeserializeAsync( stream, OAuthJsonContext.Default.ClientRegistrationRequest, context.RequestAborted); if (registrationRequest is null) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid registration request" }); } LastRegistrationScope = registrationRequest.Scope; LastApplicationType = registrationRequest.ApplicationType; // Validate redirect URIs are provided if (registrationRequest.RedirectUris.Count == 0) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_redirect_uri", ErrorDescription = "At least one redirect URI must be provided" }); } // Validate redirect URIs foreach (var redirectUri in registrationRequest.RedirectUris) { if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri) || (uri.Scheme != "http" && uri.Scheme != "https")) { return Results.BadRequest(new OAuthErrorResponse { Error = "invalid_redirect_uri", ErrorDescription = $"Invalid redirect URI: {redirectUri}" }); } } // Generate client credentials var clientId = $"dyn-{Guid.NewGuid():N}"; var clientSecret = GenerateRandomToken(); var issuedAt = DateTimeOffset.UtcNow; // Store the registered client _clients[clientId] = new ClientInfo { ClientId = clientId, RequiresClientSecret = true, ClientSecret = clientSecret, RedirectUris = registrationRequest.RedirectUris, }; var registrationResponse = new ClientRegistrationResponse { ClientId = clientId, ClientSecret = clientSecret, ClientIdIssuedAt = issuedAt.ToUnixTimeSeconds(), RedirectUris = registrationRequest.RedirectUris, GrantTypes = ["authorization_code", "refresh_token"], ResponseTypes = ["code"], TokenEndpointAuthMethod = "client_secret_post", }; return Results.Ok(registrationResponse); }); app.MapGet("/", () => "Demo In-Memory OAuth 2.0 Server with JWT Support"); Console.WriteLine($"OAuth Authorization Server running at {_url}"); Console.WriteLine($"OAuth Server Metadata at {_url}/.well-known/oauth-authorization-server"); Console.WriteLine($"JWT keys available at {_url}/.well-known/jwks.json"); Console.WriteLine($"Demo Client ID: {clientId}"); Console.WriteLine($"Demo Client Secret: {clientSecret}"); await app.StartAsync(cancellationToken); _serverStarted.TrySetResult(); // Wait until cancellation is requested try { await Task.Delay(Timeout.Infinite, cancellationToken); } catch (OperationCanceledException) { // Expected when cancellation is requested } await app.StopAsync(); } /// /// Authenticates a client based on client credentials in the request. /// /// The HTTP context. /// The form collection containing client credentials. /// The client info if authentication succeeds, null otherwise. private ClientInfo? AuthenticateClient(HttpContext context, IFormCollection form) { var clientId = form["client_id"].ToString(); var clientSecret = form["client_secret"].ToString(); if (string.IsNullOrEmpty(clientId) || !_clients.TryGetValue(clientId, out var client)) { return null; } if (client.RequiresClientSecret && client.ClientSecret != clientSecret) { return null; } return client; } /// /// Generates a JWT token response. /// /// The client ID. /// The approved scopes. /// The resource URI. /// A token response. private TokenResponse GenerateJwtTokenResponse(string clientId, List scopes, Uri? resource) { var expiresIn = TimeSpan.FromHours(1); var issuedAt = DateTimeOffset.UtcNow; var expiresAt = issuedAt.Add(expiresIn); var jwtId = Guid.NewGuid().ToString(); // Create JWT header and payload var header = new Dictionary { { "alg", "RS256" }, { "typ", "JWT" }, { "kid", _keyId }, }; var payload = new Dictionary { { "iss", _url }, { "sub", $"user-{clientId}" }, { "name", $"user-{clientId}" }, { "aud", resource?.ToString() ?? clientId }, { "client_id", clientId }, { "jti", jwtId }, { "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, { "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, { "scope", string.Join(" ", scopes) }, }; // Create JWT token var headerJson = JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString); var payloadJson = 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); var signatureBase64 = WebEncoders.Base64UrlEncode(signature); var jwtToken = $"{headerBase64}.{payloadBase64}.{signatureBase64}"; // Generate opaque refresh token var refreshToken = GenerateRandomToken(); // Store token info (for refresh token and introspection) var tokenInfo = new TokenInfo { ClientId = clientId, Scopes = scopes, IssuedAt = issuedAt, ExpiresAt = expiresAt, Resource = resource, JwtId = jwtId }; _tokens[refreshToken] = tokenInfo; return new TokenResponse { AccessToken = jwtToken, RefreshToken = refreshToken, TokenType = "Bearer", ExpiresIn = (int)expiresIn.TotalSeconds, Scope = string.Join(" ", scopes) }; } /// /// 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). /// 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 { { "alg", "RS256" }, { "typ", "JWT" }, { "kid", _keyId }, }; var payload = new Dictionary { { "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)}"; } /// /// Decodes a JWT payload (without signature verification) and returns the value of /// , or null if the claim is absent or the JWT is malformed. /// 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; } } /// /// Generates a random token for authorization code or refresh token. /// /// A Base64Url encoded random token. public static string GenerateRandomToken() { var bytes = new byte[32]; Random.Shared.NextBytes(bytes); return WebEncoders.Base64UrlEncode(bytes); } /// /// Verifies a PKCE code challenge against a code verifier. /// /// The code verifier to verify. /// The code challenge to verify against. /// True if the code challenge is valid, false otherwise. public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge) { using var sha256 = SHA256.Create(); var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); var computedChallenge = WebEncoders.Base64UrlEncode(challengeBytes); return computedChallenge == codeChallenge; } }