Skip to main content

Posts

Showing posts from 2026

Passing custom parameters through OpenIddict's client authorization request

In my passthrough authentication post , the login endpoint set a provider value on AuthenticationProperties so the OpenIddict server would know whether to challenge GitHub or ADFS: app.MapGet("login/{provider}", (string provider) => Results.Challenge(new AuthenticationProperties { RedirectUri = "/", Items = { ["provider"] = provider } }, [OpenIddictClientAspNetCoreDefaults.AuthenticationScheme])); Run that as-is and the server never sees it. Items round-trips through your own app's correlation cookie — it was never meant to become part of the outgoing OAuth2 request. Why this doesn't just work AuthenticationProperties.Items is an ASP.NET Core concept. It's how the authentication middleware remembers things like the redirect URI across the round trip to an external provider and back. The OpenIddict client builds the actual /connect/authorize request from a completely different object — an OpenIddictReq...

Passthrough authentication with OpenIddict

In most enterprise applications authentication is externalized to an external identity provider. For our applications we had to support multiple identity providers. How can we do this without tight coupling to each of these providers? In this post we'll bring OpenIddict into the picture and show how it helps to solve this nicely. What is OpenIddict? OpenIddict is an open-source OAuth2/OpenID Connect server and client stack for .NET. It plugs into ASP.NET Core Identity (or your own user store) and lets you run your own authorization server instead of depending fully on an external one. You get the standard endpoints — /connect/authorize , /connect/token , /connect/userinfo — backed by your own database and your own claims. That's exactly what makes the passthrough pattern possible: OpenIddict is the piece in the middle that can defer to external providers for the actual authentication, then still be the one issuing the token your APIs trust. The naive approach The ...

Why "AI is just the next compiler" doesn't hold up

At a recent talk I compared AI adoption to the introduction of compilers: a new layer of abstraction that lets us work at a higher level, the same way compilers let us stop writing assembly by hand. In the hallway afterwards, a few people pushed back on that comparison. They were right to. Here's the point I should have made from the stage: agents are not deterministic, and that single difference breaks the analogy. Compilers follow rules A compiler is not a magical black box. You give it code, and it gives you a binary, following a fixed set of rules. Given the same input, a compiler will reliably produce the same output, every time. You don't hope the binary does what you wrote. You trust the compiler, because the transformation is deterministic. That determinism is exactly what let us move up the abstraction ladder in the first place. We stopped worrying about registers and memory addresses because we could trust the layer below us to behave the same way twice....

Reading a Profiler trace without guessing

Note: this is part 3 of a series on the Azure Monitor Profiler. Part 1 covered what the Profiler is, part 2 covered enabling it. This post is about the part that actually matters: making sense of a trace once you have one. Having profiler traces is only half the job. I've seen people enable the profiler, open a trace, stare at a wall of unfamiliar method names, and close the tab. The trace explorer isn't self-explanatory the first time - it took me a few real incidents before the views clicked. This post is the walkthrough I wish I'd had. Getting to a trace From your Application Insights resource: Go to the Performance tab Pick an operation from the list or leave Overall selected Click on Profiler traces   Pick one of the captured requests, ideally one with a longer duration than usual - that's where you'll actually find something. Once you're in a trace, you get two views of the same call stack data: Flame graph - the full...

Enabling the Application Insights Profiler

Note: this is part 2 of a series on the Azure Monitor Profiler. Part 1 covered what the Profiler is and where to find the results - this post covers actually turning it on. There are two ways to enable the profiler: through app settings on App Service, or by wiring it into your code directly. Which one you need depends on where your app runs and how much control you want over the setup. Option 1: codeless enablement on App Service If your app runs on App Service (Windows) and your Application Insights resource is in the same subscription, this is the easiest path - no code changes, no redeploy. From the portal: In your App Service instance, select Monitoring > Application Insights Select Turn on Application Insights , then Enable Scroll down to the .NET or .NET Core tab Set Collection level to Recommended Under Profiler and Code Optimizations , select On Apply , then confirm with Yes Or skip the portal entirely and set the app settings directly...

Our P95 spiked, now what?

Yesterday one of our Application Insights dashboard showed a P95 latency spike and we had no idea why. The telemetry told us that a request was slow. But it didn't tell us why . Was it a database call? A CPU-bound loop? Lock contention? A GC pause? The naive approach would have to been to add logging statements around the code you suspect, redeploy, wait for the issue to reproduce, and repeat. In production, that's slow and it doesn't scale - you're guessing, and every guess costs a deployment cycle. We stayed away from all that guess work and reached out to the Application Insights Profiler . Instead of reasoning from logs, you get actual flame graphs of real production requests, showing exactly where time was spent. What the profiler actually captures The profiler runs as an agent alongside your application and periodically captures traces of live requests - not synthetic load, actual production traffic. For each captured request, it builds a trace you can in...

.NET Aspire: The price of forgetting WithReference

Recently I lost way more time than I'd like to admit on an error that turned out to be one missing line of code. The symptom looked like a networking problem. The cause was a missing WithReference call in my Aspire AppHost. Here's the exception my proxy threw the moment it tried to forward a request to my API: System.Net.Http.HttpRequestException: No such host is known. (api:443) ---> System.Net.Sockets.SocketException (11001): No such host is known. at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken) at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token) at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken) --- End of inner exception stack trace --- at System.Net.Http.HttpConnectionPool....

The mysterious .dev.localhost checkbox

When you create a new ASP.NET Core project in Visual Studio, there's a checkbox that's easy to click past: "Use the .dev.localhost TLD in the application URL." I always want to understand what a checkbox actually does before I tick it, so let's dig into this one. The problem it solves When you're working on more than one local web project, they all end up living at the same address: localhost . Only the port number tells them apart. Open your browser's address bar with three projects running and you'll see localhost:5001 , localhost:5215 , localhost:7099 — and you have no idea which is which until you actually look at the page. There's a second, less visible issue: because everything shares the localhost name, cookies and other domain-scoped browser storage are also shared across all your local apps. That's not something you usually want when you're testing. What .dev.localhost actually is .localhost is a reserved top-level dom...

Talking to Copilot like a caveman

  I think that everyone who uses AI recognizes the following pattern; you ask an LLM a simple question and it answers like it's writing a blog post: introduction, context, three examples, a closing summary. Fine for a first read, expensive when you're chaining calls or running an agent loop all day. The trick to avoid this is called "caveman prompting". You tell the model to drop articles, pleasantries and filler, and answer in short, blunt fragments. It sounds silly. But it works up to a point. A first attempt: just say "be concise" Most people's first instinct is a one-line system prompt: Be concise. No fluff. This already gets you a good chunk of the savings. In benchmarks I've seen floating around, a plain "be concise, return structured output" instruction accounts can already give you a nice reduction. It's the cheapest fix and most people stop here, which is reasonable. The caveman approach The caveman skill takes...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

A complex system designed from scratch never works

A few years ago, I worked as an architect on a big mainframe rewrite. I still count it as one of my failures. Not because the technology was wrong, but because I couldn't convince the management team to simplify the approach. Years later, the organization is still struggling to get the new system up and running. I left the project at the time, because I couldn't put my name behind an approach that would take very long and cost a lot of money without a working system to show for it along the way. Gall’s Law That memory keeps coming back to me, because it's a textbook case of Gall's Law playing out in real life. Gall's Law , from John Gall's Systemantics , states it plainly: A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works, and it cannot be patched to make it work. You have to start over with a simple system that works. What does that mean in practice,...

Fixing "Filename too long" errors on Windows with Git

There's a moment when you clone or pull a repository on Windows and Git throws an error like this: error: unable to create file some/very/deeply/nested/path/to/a/file.ts: Filename too long Nothing wrong with your code, nothing wrong with the repo. It's Windows. Why does this happen? Windows has a default path length limitation of 260 characters (the infamous MAX_PATH ). Git operations that create files with a full path longer than that — cloning, checking out, pulling — will fail with this error. Repositories with deeply nested folder structures (think node_modules, or generated code) hit this constantly. The fix: enable long paths in Git Git has a config setting for exactly this: core.longpaths . You have two ways to set it, depending on your rights on the machine. System-wide (requires Administrator privileges): git config --system core.longpaths true User-level (no Administrator required): git config --global core.longpaths true If y...

YARP and Aspire: "https+http scheme is not supported"

Recently I was wiring up a YARP reverse proxy in front of a couple of Aspire-managed services: an API and an Angular frontend. Aspire gives you service discovery for free, so the obvious move is to point your YARP clusters at the logical service names instead of hardcoded URLs. My first attempt looked like this: "Clusters": { "api-cluster": { "Destinations": { "api-destination": { "Address": "https+http://api" } } }, "frontend-cluster": { "Destinations": { "frontend-destination": { "Address": "https+http://angular-frontend" } } } } The https+http:// scheme is the standard Aspire service discovery convention: try HTTPS first, fall back to HTTP. It works fine when you're resolving endpoints through HttpClient . Unfortunately YARP doesn’t like this configuration. After setting it up with these values ...