---
title: "Run an OAuth authorization server in .NET"
url: https://recursiveai.org/onemodelcontext/cookbook/run-an-authorization-server/
description: "Host OAuth authorization endpoints for a Model Context Protocol® resource in .NET. Includes a complete local code exchange without external accounts."
version: "1.0.1"
---

# Run an OAuth authorization server in .NET

**OneModelContext** is a free, open-source .NET SDK for the Model Context Protocol® from Recursive AI. MIT or Apache-2.0, no native dependencies.

[NuGet](https://www.nuget.org/packages/RecursiveAI.OneModelContext) · [GitHub](https://github.com/recursive-ai-oss/onemodelcontext-csharp) · [Docs](https://recursiveai.org/onemodelcontext/docs/) · [Download example (.zip)](https://recursiveai.org/onemodelcontext/cookbook/run-an-authorization-server/run-an-authorization-server.zip)

An authorization server issues tokens for a protected Model Context Protocol® (MCP) tool endpoint. OneModelContext handles OAuth; your application supplies login and consent.

```shell
dotnet add package RecursiveAI.OneModelContext
```

Requires [.NET 10](https://dotnet.microsoft.com/download/dotnet/10.0) (net10.0); .NET 9 and earlier aren’t supported.

```csharp
using System.Security.Cryptography;
using RecursiveAI.OneModelContext.Auth.Server;
using RecursiveAI.OneModelContext.Http;
using RecursiveAI.OneModelContext.Jose;

using var key = ECDsa.Create();
using var auth = new AuthorizationServer(new()
{
    Issuer = new("https://auth.example.com"),
    Resources = ["https://app.example.com/tools"],
    SigningKeys =
        [JsonWebSigningKey.FromECDsa(key, "demo")],
    ScopesSupported = ["read"],
    AuthorizeUser = (_, _) => ValueTask.FromResult(
        AuthorizationDecision.Deny()),
});
await using var http = new HttpServer(
    async exchange =>
    { await auth.HandleAsync(exchange); });
http.Start();
Console.WriteLine("Authorization server started.");
Console.WriteLine($"Listening: {http.BaseAddress}");
Console.ReadLine();
```

Unzip [the example](https://recursiveai.org/onemodelcontext/cookbook/run-an-authorization-server/run-an-authorization-server.zip), then `dotnet run`.

The snippet at the top prints `Authorization server started.` and its local listening address, then waits for Enter. It listens on IPv4 loopback at an available port; metadata is at `/.well-known/oauth-authorization-server` relative to the printed address. The example public URLs are metadata, not local bindings; configure the real public issuer and resource URLs in your deployed host. Its callback denies authorization until you implement login and consent.

The downloaded program is different: `dotnet run` prints `Anonymous: 401` followed by `Authorized: 42`, as shown below, then exits.

The full program below starts a temporary listener, registers a demonstration client, then performs an authorization-code exchange and calls a protected calculator. Its callback stands in for an already signed-in user who approved the requested scope. It never opens a browser, uses an external account or prints a token. The key and in-memory store exist only for this run.

As the [authorization-server guide](https://recursiveai.org/onemodelcontext/docs/authorization-server/) states, “the server never renders a login page itself.” The anonymous connection throws `AuthorizationException` with status 401; the example catches it before trying the authorized connection.

## The full program

```csharp
using System.Net;
using System.Security.Cryptography;
using RecursiveAI.OneModelContext;
using RecursiveAI.OneModelContext.Auth;
using RecursiveAI.OneModelContext.Auth.Server;
using RecursiveAI.OneModelContext.Hosting;
using RecursiveAI.OneModelContext.Http;
using RecursiveAI.OneModelContext.Jose;
using RecursiveAI.OneModelContext.OAuth;

using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
AuthorizationServer? authorization = null;
ResourceServer? resource = null;
StreamableHttpEndpoint? endpoint = null;
await using var listener = new HttpServer(async exchange =>
{
    if (authorization!.IsServerPath(exchange.Request.Path))
        await authorization.HandleAsync(exchange);
    else if (exchange.Request.Path == "/tools" || resource!.IsMetadataPath(exchange.Request.Path))
        await endpoint!.HandleAsync(exchange);
    else
        exchange.Response.StatusCode = 404;
});
listener.Start();
var issuer = new Uri(listener.BaseAddress!.GetLeftPart(UriPartial.Authority));
var url = new Uri(issuer, "/tools");
var redirect = new Uri($"http://{IPAddress.Loopback}:9/callback");
using var auth = new AuthorizationServer(new AuthorizationServerOptions
{
    Issuer = issuer,
    Resources = [url.AbsoluteUri],
    SigningKeys = [JsonWebSigningKey.FromECDsa(key, "demo")],
    ScopesSupported = ["calculate"],
    Store = new MemoryAuthorizationStore(),
    // Local demonstration only: stand in for a signed-in user and consent.
    AuthorizeUser = (request, _) => ValueTask.FromResult(
        AuthorizationDecision.Approve("alice", request.RequestedScopes)),
});
authorization = auth;
await auth.Store.SaveClientAsync(new RegisteredClient
{
    ClientId = "demo-app",
    RedirectUris = [redirect.AbsoluteUri],
    Scopes = ["calculate"],
    CreatedAt = DateTimeOffset.UtcNow,
}, CancellationToken.None);
resource = new ResourceServer(new ResourceServerOptions(url, auth.CreateTokenValidator())
{
    AuthorizationServers = [issuer.GetLeftPart(UriPartial.Authority)],
    ScopesSupported = ["calculate"],
    RequiredScopes = ["calculate"],
});
var server = new ContextServer("protected-calculator", "1.0.0");
new Calculator().AddTo(server);
using var handler = new StreamableHttpEndpoint(server,
    new StreamableHttpOptions { ResourceServer = resource });
endpoint = handler;

try
{
    await using var anonymous = await ModelContext.ConnectAsync(ContextTransport.Http(url));
}
catch (AuthorizationException ex)
{
    Console.WriteLine($"Anonymous: {ex.StatusCode}");
}
using var oauth = new OAuthClient(new OAuthClientOptions(url)
{
    UserAgent = new DemoBrowser(redirect),
    PreRegistered = new PreRegisteredClient("demo-app", Issuer: issuer.GetLeftPart(UriPartial.Authority)),
});
using var http = oauth.CreateHttpClient();
await using (var client = await ModelContext.ConnectAsync(ContextTransport.Http(url, http)))
    Console.WriteLine("Authorized: " + (await client.CallToolAsync("add", new { a = 2, b = 40 })).Text);

// Simulates the browser redirect without opening a window or printing tokens.
sealed class DemoBrowser(Uri redirect) : IAuthorizationUserAgent
{
    public Uri RedirectUri => redirect;
    public async ValueTask<Uri> AuthorizeAsync(Uri url, CancellationToken cancellationToken)
    {
        using var browser = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false });
        using var response = await browser.GetAsync(url, cancellationToken);
        if (response.StatusCode != HttpStatusCode.Found)
            throw new InvalidOperationException("Authorization did not redirect.");
        return response.Headers.Location!;
    }
}

public partial class Calculator
{
    /// <summary>Add two integers.</summary>
    [Tool]
    public int Add(int a, int b) => a + b;
}
```

`dotnet run` prints:

```text
Anonymous: 401
Authorized: 42
```

## Variations

Keep server records across restarts of a single process. Replace the full program’s `Store` assignment with:

```csharp
Store = new FileAuthorizationStore("oauth-demo.json"),
```

The run prints the same two lines and creates `oauth-demo.json`. This stores authorization records, not the signing key; production also needs persistent signing keys. Don’t share this single-process file store between server instances.

## Related recipes

- [Build a server in C#](https://recursiveai.org/onemodelcontext/cookbook/build-a-server-in-csharp/)
- [Connect to a server](https://recursiveai.org/onemodelcontext/cookbook/connect-to-a-server/)
- [Require OAuth sign-in on a server](https://recursiveai.org/onemodelcontext/cookbook/require-sign-in-on-a-server/)

---

## The short answer

For an MCP authorization server in .NET (Model Context Protocol®), construct `AuthorizationServer`, route its owned paths to `HandleAsync`, and provide signing keys, allowed resources, supported scopes and an `AuthorizeUser` callback. Protect the tool endpoint separately with a `ResourceServer` that validates the issued tokens. Hosting token endpoints and enforcing access on a tool endpoint are different responsibilities.

## How do I run an authorization server in .NET?

OneModelContext embeds the authorization protocol in your application. The snippet at the top creates a server with an ephemeral signing key and a local HTTP listener. It serves authorization metadata and endpoints, but its callback refuses authorization. It is a starting point for hosting, not a login implementation.

The full program goes further without depending on another service. It registers `demo-app` with an exact redirect URI, allows the `calculate` scope, and configures a resource server for the calculator URL. The client then discovers authorization information, gets a code through the demonstration browser callback, exchanges it and calls `add`. The anonymous request is refused before the authorized call succeeds.

Use this local example to understand the handoff points. For a deployed application, the issuer must describe the public HTTPS authorization service, and the resource must identify the actual protected endpoint. A local listener with example public URLs does not deploy either service at those URLs. In ASP.NET Core, the documented hosting alternative is `MapAuthorizationServer`; in other hosts, route only paths owned by the authorization server.

## Does an authorization server provide a login page?

No. The [authorization-server documentation](https://recursiveai.org/onemodelcontext/docs/authorization-server/) explicitly keeps login and consent with the host. `AuthorizeUser` receives the client identity, redirect URI, requested scopes, resource and browser exchange. It can approve, deny, redirect to your login route or return an interaction response. Approval should represent both an authenticated user and that user’s consent.

The download approves a fixed demonstration subject so it can exercise a complete exchange unattended. Do not use that callback as a production sign-in policy. The smaller snippet denies instead. Neither example claims to authenticate a person.

After login, use the documented resume URL to return the browser to the pending authorization request. The callback then sees the request again and can approve the scopes the user accepted. An approval can narrow the requested scope set; it is not a way to add unrelated permissions.

## How do I protect the tool endpoint with the issued tokens?

Configure `ResourceServer` with the protected resource URL and the embedded server’s `CreateTokenValidator()`, as the full program does. The endpoint uses that resource-server configuration when handling tool requests. The built-in validator also checks the revocation state documented for the embedded server.

Returning discovery metadata alone is not enforcement. The example’s first connection is deliberately anonymous and receives 401. Only the subsequent connection uses the OAuth-configured HTTP client. That contrast shows where token acquisition ends and protected resource access begins; it is not an assertion that any client with metadata may invoke a tool.

Keep an explicit scope allow-list. The documentation says interactive requests can receive any well-formed scope if neither the server nor the client constrains scopes. The example constrains both, then requires `calculate` on the resource. Client Credentials additionally needs a pre-registered authenticated client, an allowed grant type and an applicable scope allow-list; it does not stand in for an interactive user’s consent.

## How do I persist authorization server state?

The default in-memory store belongs to one process and is lost on restart. The file-store variation writes authorization records atomically to one JSON file and is also single-process. Multiple instances require a database store implementing the documented atomic operations. Choose storage together with your deployment model rather than pointing several replicas at the same file.

Signing keys have their own lifecycle. The ephemeral key here is appropriate only to an example. Persist production keys, keep the private material private, and rotate them using the documented key management APIs. The JWKS endpoint publishes the public keys; it is not a backup of the private signing key. Keeping the authorization-record file does not preserve a newly generated key across restarts.

The embedded service also does not provide consent screens, browser CORS policy or endpoint rate limiting. Those remain host responsibilities. It is not an OpenID Connect provider and does not issue `id_token`s, even though metadata is available at an OpenID discovery path. For the exact supported grants and endpoint behavior, use the [authorization-server guide](https://recursiveai.org/onemodelcontext/docs/authorization-server/) rather than assuming every identity-provider feature is included.
