The core challenge was straightforward on the surface: a mature ASP.NET Core MVC monolith, holding all user data and session logic, needed to delegate a new, high-throughput data processing feature to a separate FastAPI microservice. The non-negotiable user experience constraint was seamless authentication. A user logged into the C# application must be automatically authenticated when redirected to the FastAPI service, without a second login prompt. The initial path of creating a shared session database was immediately discarded due to performance bottlenecks and tight coupling. This led us down the path of a custom, lightweight identity federation using OAuth 2.0’s Authorization Code Flow, with Redis acting as the high-speed, ephemeral state bridge between the two distinct technology stacks.
In a real-world project, building a full-blown Identity Provider is often overkill for internal service-to-service federation. Our goal was not to replace Okta or Keycloak, but to implement a secure, low-latency handshake. The C# monolith would act as the Authorization Server, and the new FastAPI service would be the Client.
The complete flow looks like this:
sequenceDiagram participant UserAgent as User's Browser participant CSharpMonolith as C# ASP.NET Core (Auth Server) participant Redis as Redis Cache participant FastAPIService as FastAPI Service (Client) UserAgent->>CSharpMonolith: 1. Clicks link to new feature CSharpMonolith->>CSharpMonolith: 2. Verifies user is logged in CSharpMonolith->>Redis: 3. Generates and stores auth_code (key: code, value: user_id) with short TTL (e.g., 60s) CSharpMonolith-->>UserAgent: 4. Responds with 302 Redirect to FastAPI callback Note right of UserAgent: Redirect URL: https://fastapi.service/auth/callback?code=xyz123 UserAgent->>FastAPIService: 5. Follows redirect to callback URL FastAPIService->>CSharpMonolith: 6. Makes direct, server-to-server request to /token endpoint Note left of FastAPIService: Request body: { "code": "xyz123", "client_id": "...", "client_secret": "..." } CSharpMonolith->>Redis: 7. Looks up "xyz123" alt Code is valid and found Redis-->>CSharpMonolith: 8a. Returns user_id CSharpMonolith->>Redis: 8b. DELETES "xyz123" to prevent replay attacks Note right of CSharpMonolith: A common mistake is not consuming the code immediately. CSharpMonolith->>CSharpMonolith: 9. Fetches user details CSharpMonolith-->>FastAPIService: 10. Responds 200 OK with user profile/session token else Code is invalid, expired, or used Redis-->>CSharpMonolith: 8a. Returns nil CSharpMonolith-->>FastAPIService: 10. Responds 400 Bad Request end alt Token exchange successful FastAPIService->>FastAPIService: 11. Creates its own local session (e.g., JWT) FastAPIService-->>UserAgent: 12. Sets session cookie and redirects to feature page else Token exchange fails FastAPIService-->>UserAgent: 12. Redirects to an error page end
Part 1: The C# Authorization Server
The monolith needs to expose two critical endpoints: /authorize
to initiate the flow and /token
for the server-to-server code exchange. We’ll use the StackExchange.Redis
client for interacting with Redis.
First, the project setup in the C# .csproj
file must include the Redis client:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.6.122" />
</ItemGroup>
</Project>
Next, configure the Redis connection in Program.cs
. In a production scenario, this would come from appsettings.json
or environment variables.
// Program.cs
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
// --- Redis Configuration ---
// In a real project, this connection string would be in a secure configuration source.
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(redisConnectionString));
// --- Simulated User Store & Client Store ---
// For this example, we'll use a simple in-memory store. In production, this would be a database.
var clients = new Dictionary<string, string>
{
{ "fastapi_service", "a_very_secret_string_for_fastapi" }
};
builder.Services.AddSingleton(clients);
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// This is a simplified simulation of authentication middleware.
// In a real ASP.NET Core app, you would use Cookie Authentication or similar.
app.Use(async (context, next) =>
{
// Simulate a logged-in user for demonstration purposes.
// In a real system, this would be derived from a session cookie.
var userId = "user-12345";
context.Items["UserId"] = userId;
await next.Invoke();
});
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
The core logic resides within a new AuthController
. This controller manages the generation and validation of the authorization code.
// Controllers/AuthController.cs
using Microsoft.AspNetCore.Mvc;
using StackExchange.Redis;
using System.Security.Cryptography;
using System.Text.Json;
namespace CSharpMonolith.Controllers;
[ApiController]
[Route("[controller]")]
public class AuthController : ControllerBase
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<AuthController> _logger;
private readonly Dictionary<string, string> _clients;
// These would be loaded from configuration.
private const string FastAPI_Redirect_URI = "http://localhost:8000/auth/callback";
public AuthController(IConnectionMultiplexer redis, ILogger<AuthController> logger, Dictionary<string, string> clients)
{
_redis = redis;
_logger = logger;
_clients = clients;
}
[HttpGet("authorize")]
public IActionResult Authorize(string response_type, string client_id, string redirect_uri)
{
// 1. Basic validation of the authorization request
if (response_type != "code")
{
return BadRequest("Invalid response_type. Only 'code' is supported.");
}
if (client_id != "fastapi_service" || redirect_uri != FastAPI_Redirect_URI)
{
_logger.LogWarning("Invalid client_id or redirect_uri received.");
return BadRequest("Invalid client or redirect URI.");
}
// 2. Ensure user is authenticated (simulated via middleware)
if (!HttpContext.Items.TryGetValue("UserId", out var userIdObj) || userIdObj == null)
{
// In a real app, this would redirect to the login page.
return Unauthorized("User is not authenticated.");
}
var userId = userIdObj.ToString();
// 3. Generate a cryptographically secure authorization code
var authCode = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
// 4. Store the code in Redis with a short expiry
// The key is prefixed to avoid collisions. The value is the user ID.
// A short TTL (e.g., 60 seconds) is crucial for security.
var db = _redis.GetDatabase();
var key = $"auth_code:{authCode}";
db.StringSet(key, userId, TimeSpan.FromSeconds(60));
_logger.LogInformation("Generated auth code for user {UserId} and client {ClientId}", userId, client_id);
// 5. Redirect the user's browser back to the client application
var redirectUrl = $"{redirect_uri}?code={authCode}";
return Redirect(redirectUrl);
}
public record TokenRequest(string grant_type, string code, string client_id, string client_secret);
[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] TokenRequest request)
{
// 1. Validate the token request
if (request.grant_type != "authorization_code")
{
return BadRequest(new { error = "unsupported_grant_type" });
}
if (!_clients.TryGetValue(request.client_id, out var storedSecret) || storedSecret != request.client_secret)
{
_logger.LogWarning("Token request with invalid client credentials for client_id: {ClientId}", request.client_id);
return Unauthorized(new { error = "invalid_client" });
}
// 2. Retrieve the code from Redis and immediately delete it
var db = _redis.GetDatabase();
var key = $"auth_code:{request.code}";
// The pitfall here is not making this operation atomic.
// A Lua script could be used for a more robust check-and-delete.
// For simplicity, we perform two separate operations.
var userId = await db.StringGetAsync(key);
if (userId.IsNullOrEmpty)
{
_logger.LogWarning("Token request with invalid, expired, or used code: {Code}", request.code);
return BadRequest(new { error = "invalid_grant", error_description = "Authorization code is invalid or has expired." });
}
// A common mistake is to forget to consume the code.
// This prevents replay attacks where the same code is used multiple times.
await db.KeyDeleteAsync(key);
_logger.LogInformation("Successfully exchanged code for user {UserId}", userId);
// 3. In a real scenario, you'd generate a JWT access token here.
// For this example, we'll return user information directly.
var userProfile = new
{
user_id = userId.ToString(),
name = "John Doe", // Fetched from database
email = "[email protected]"
};
return Ok(new
{
access_token = "dummy-access-token-for-future-use", // This would be a real JWT
token_type = "Bearer",
expires_in = 3600,
user_info = userProfile
});
}
}
The key security considerations implemented here are:
- Using a cryptographically random string for the authorization code.
- Setting a very short Time-To-Live (TTL) on the code in Redis (60 seconds).
- Deleting the code from Redis immediately after it’s used to prevent replay attacks.
- Validating the
client_id
andclient_secret
during the server-to-server token exchange.
Part 2: The FastAPI Client Service
On the Python side, we need a service that can handle the callback from the C# monolith and orchestrate the token exchange. We’ll use httpx
for making the server-to-server request and redis-py
for session storage.
The project structure would be simple:
fastapi-service/
├── app/
│ ├── __init__.py
│ └── main.py
└── requirements.txt
The requirements.txt
file:
fastapi
uvicorn[standard]
pydantic
httpx
redis
python-dotenv
The core application logic is in app/main.py
. We use Pydantic models for data validation and structure.
# app/main.py
import logging
import os
from contextlib import asynccontextmanager
import httpx
import redis.asyncio as redis
from dotenv import load_dotenv
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
# --- Configuration ---
load_dotenv()
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# C# Monolith Configuration
CSHARP_AUTH_SERVER_URL = os.getenv("CSHARP_AUTH_SERVER_URL", "https://localhost:7289")
CSHARP_TOKEN_ENDPOINT = f"{CSHARP_AUTH_SERVER_URL}/auth/token"
# This FastAPI service's client credentials
CLIENT_ID = "fastapi_service"
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "a_very_secret_string_for_fastapi")
# Redis connection pool
redis_pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global redis_pool
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
redis_pool = redis.from_url(redis_url, decode_responses=True)
logging.info("Redis connection pool created.")
yield
await redis_pool.close()
logging.info("Redis connection pool closed.")
app = FastAPI(lifespan=lifespan)
# --- Pydantic Models for Type Safety ---
class TokenRequest(BaseModel):
grant_type: str = "authorization_code"
code: str
client_id: str
client_secret: str
class UserInfo(BaseModel):
user_id: str
name: str
email: str
class TokenResponse(BaseModel):
access_token: str
token_type: str
expires_in: int
user_info: UserInfo
# --- Authentication Flow Endpoints ---
@app.get("/auth/callback")
async def auth_callback(request: Request, code: str | None = None):
if not code:
raise HTTPException(status_code=400, detail="Authorization code not found in callback.")
logging.info(f"Received authorization code: {code[:10]}...")
# The backend makes a direct, server-to-server call to exchange the code for a token.
# This is a critical step; the token is never exposed to the user's browser.
token_request_payload = TokenRequest(
code=code,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET
)
try:
async with httpx.AsyncClient(verify=False) as client: # In production, DO NOT use verify=False
response = await client.post(
CSHARP_TOKEN_ENDPOINT,
json=token_request_payload.dict(),
headers={"Content-Type": "application/json"}
)
# A robust implementation would handle network errors, timeouts, etc.
response.raise_for_status() # Raises HTTPStatusError for 4xx/5xx responses
token_data = response.json()
token_response = TokenResponse(**token_data)
logging.info(f"Successfully exchanged code for user: {token_response.user_info.user_id}")
# Here, we would create a local session for the user.
# This could be a JWT stored in a secure, HTTPOnly cookie.
# For simplicity, we'll just log it and redirect.
# In a real app:
# session_id = create_session(token_response.user_info)
# response = RedirectResponse(url="/dashboard")
# response.set_cookie(key="session_id", value=session_id, httponly=True, secure=True)
# return response
return {"status": "success", "user": token_response.user_info}
except httpx.HTTPStatusError as e:
# This catches errors from the C# token endpoint (e.g., invalid code)
error_details = e.response.json() if e.response.content else {"error": "unknown_error"}
logging.error(f"Failed to exchange token. Status: {e.response.status_code}, Details: {error_details}")
raise HTTPException(status_code=401, detail=f"Authentication failed: {error_details.get('error_description', 'Invalid grant')}")
except Exception as e:
logging.error(f"An unexpected error occurred during token exchange: {e}")
raise HTTPException(status_code=500, detail="Internal server error during authentication.")
@app.get("/dashboard")
async def get_dashboard(request: Request):
# In a real app, this would be a protected endpoint.
# It would validate the session cookie created in the callback.
return {"message": "Welcome to the FastAPI dashboard! You are authenticated."}
To run this setup:
- Start the Redis server.
- Run the C# ASP.NET Core application.
- Run the FastAPI application using
uvicorn app.main:app --reload
. - Navigate your browser to
https://localhost:7289/Auth/authorize?response_type=code&client_id=fastapi_service&redirect_uri=http://localhost:8000/auth/callback
.
This simulates the initial click from the monolith. The browser will be redirected to the FastAPI service, which completes the handshake in the background and presents the final authenticated page.
The design decision to use Redis as an intermediary is crucial. It provides a shared, high-performance, and stateless communication channel. The C# service writes a temporary credential, and the FastAPI service reads it. Because the credential (the authorization code) is single-use and has a short TTL, the security posture is strong. This avoids the complexities of a shared database schema, replication lag, or the overhead of more permanent storage for a value that only needs to exist for a few seconds.
The limitations of this approach must be clearly understood. This is a bespoke, point-to-point integration. If a third or fourth service needs to join this authentication ecosystem, the C# monolith would have to be updated for each one. The implementation does not follow the full OpenID Connect (OIDC) specification, as it omits features like the id_token
, discovery endpoints (.well-known/openid-configuration
), and dynamic client registration. For a more complex microservices landscape, transitioning this custom implementation to a dedicated identity server like Keycloak or IdentityServer, which both services would treat as an external provider, would be the correct architectural evolution. This would decouple the services from the responsibility of being an identity provider and centralize security policy management.