Skip to main content

Keystone OpenIddict Spike — Results

Status: Complete. This is the "Side 2" evidence document keystone-framing.md §5 item 1 and §10 item 2 explicitly call for — a real, lived data point to weigh against entra-cost-tally.md's Side 1 (Entra's real historical cost, ~18 incidents over five months), not a reasoned-in-the-abstract estimate. Full session-by-session detail lives in keystone-spike-build-log.md; this document synthesizes it into what actually matters for the decision framed in keystone-framing.md.

Read first: keystone-spike-scope.md for what this spike set out to prove, and keystone-framing.md for the full decision context this feeds into.


1. The headline result

All six items in keystone-spike-scope.md §2 are proven — every one against real evidence, not assumption.

#What it provesResult
1OpenIddict issuing real tokens end to end✅ Proven — genuine encrypted tokens, correct claims, spec-correct rejection on bad credentials
2Email/password registration and login✅ Proven, and exceeded — the full interactive authorization-code flow was built too, beyond the item's original scope
3Optional federation against a real Entra app registration✅ Proven against real Microsoft infrastructure — a real app registration in Traxs's own tenant, a real sign-in, real tokens, real claims
4Linking-not-coupling ExternalLogins guardrails✅ Proven — all three linking cases (already-linked, existing-account conflict, brand-new registration) built and automatically tested
5Stable person ID FK-compatible with RoundTrip's TenantUsers/Technician✅ Confirmed against real, current RoundTrip source — zero structural changes needed
6Same FK pattern against Waypoint's WaypointUsers✅ Confirmed against real, current Waypoint source — zero structural changes needed, genuinely simpler than RoundTrip's case

69 automated tests back this up — not just manual verification. Every handler, every linking case, every anti-enumeration guarantee, and (where realistically automatable) every HTTP endpoint has real, repeatable test coverage.

2. The cost data — Side 2

Total time, ground zero to full completion: ~25 hours, across 11 working sessions.

EntryWhat it coveredHours
1Solution scaffolding + OpenIddict token issuance (item 1)6
2Item 2 data layer (entities, EF config, migration)1
3Item 2 business logic (registration, login)3
4Interactive OpenIddict login flow2h 20m
5Architecture tests + domain unit tests1
6Password hasher tests, full IntegrationTests/FunctionalTests build-out1h 15m
7Program.cs composition-root refactor + spike item 3 start1h 45m
8Item 3 completed — real Microsoft Entra federation, end to end3h 30m
9Smaller cleanup: test-suite fix, Send.* convention audit, .Status/.Type, UserSecretsId root cause1h 30m
10Full test coverage for external login + Case 2 confirm-link flow2h 30m
11Items 5 and 6 — FK-compatibility checks against real schemas1
Total~24.8 hours

Worth being precise about what this number represents and doesn't. This is real, lived time for a solo developer building a genuinely new system from nothing — not a rough guess made in the abstract. It includes real debugging, real wrong turns, and real corrections, not just the time it would take to retype a known-working solution. It does not include the five source documents' own prior scoping/framing work, and it does not include production-hardening work beyond the spike's own scope (Section 4 below).

3. What the friction actually looked like

The build log's friction logs (11 entries, dozens of individual items) are the closest analogue to entra-cost-tally.md's own incident list. Rather than repeat every item here, they fall into a few real, distinct categories worth naming:

Inference risks — assumptions made before checking real RoundTrip source, caught once verified. A recurring, honest pattern especially early on: guard-clause usage before DomainException's real convention was seen, a duplicate-Mediator-registration issue, missing package references. Every one was resolved by getting the real file and correcting course — the discipline of "ask for real source before guessing" was adopted explicitly because of these early misses, and held for the rest of the build.

Known framework/EF Core gotcha classes, hit directly. A design-time DbContext factory missing a configuration call that the runtime context had, causing a PendingModelChangesWarning — this exact class of bug recurred twice, in two different files, months apart, before the underlying rule ("every KeystoneDbContext configuration needs UseOpenIddict()") fully stuck. A missing .ValueGeneratedNever() on GUID primary keys caused a real, confusing DbUpdateConcurrencyException — a genuinely valuable, previously-invisible gap found only because a test happened to exercise a mutation pattern no earlier code had used.

Third-party API surprises, resolved by checking real source rather than guessing twice. FastEndpoints' Send.RedirectAsync signature was guessed wrong twice before its real signature was checked directly. Its default auto-response behavior silently overwrote a real 302 redirect with a 204 — a subtle, hard-to-diagnose bug resolved only by checking FastEndpoints' own GitHub issue tracker for the documented, correct fix.

Real external-system integration friction — the hardest, most novel category, and the one most directly relevant to this decision. Getting Microsoft Entra federation working end to end surfaced a real, structural configuration collision (the OIDC callback path and the application's own post-sign-in redirect were configured identically, causing the identity middleware to intercept its own redirect target), a cross-site cookie/response-mode interaction specific to testing on plain HTTP, and two separate claim-name surprises (oid vs. a mapped long-form URI; email under a legacy WS-Federation name) that could only be resolved by inspecting the actual claims Entra returned rather than trusting documentation or memory.

Process gaps — genuinely worth naming, since they cost real time and weren't about the code at all. Several sessions lost time to files that were discussed and shown in chat but never actually landed in the real project (a full endpoint class, a config-wiring line, an entire new file) — resolved by adopting a strict rule of always showing complete, current file contents rather than relying on partial diffs. A machine-generated UserSecretsId was twice silently dropped during broad file replacements, root-caused only after it happened the second time.

Every one of these was resolved, and none required abandoning the approach. No dead end took more than one session to work through once correctly diagnosed — but several took real, honest back-and-forth to diagnose correctly in the first place, and that back-and-forth is faithfully reflected in the hours above.

4. What this spike deliberately did not cover

Per keystone-spike-scope.md §3's own explicit boundaries, none of the following were in scope, and none are done:

  • Command Center entitlement delegation — not even stubbed, despite the spike's own scope calling this "trivial" (a hardcoded "yes")
  • Password resetkeystone-framing.md §8's own named minimum bar for the "ready to use" tier ("nobody gets permanently locked out") is not built
  • MFA, rate-limiting, brute-force protection, admin tooling — explicitly out of scope from the start, matching Entra/Keycloak's own free-tier admin conveniences that a self-hosted system would need to build from scratch
  • The unified admin app — not built, not started
  • Case 2's full production hardening — the confirm-link flow works and is tested, but a real product would likely want additional polish (styled forms, clearer error messaging) beyond the deliberately bare implementation here
  • Real RoundTrip/Waypoint data migration — items 5 and 6 confirm compatibility, not migration; the actual cutover work is separate, named, and time-sensitive per keystone-framing.md §5 item 4

5. What this means for the decision

This document deliberately stops short of a recommendation — that's keystone-framing.md's own question to resolve, weighing this against Side 1 and the other open items in that document (Sections 5 and 6 there). What it does provide, as directly as possible: a real, lived answer to "what does a minimum-viable Keystone actually take to build and keep running solo" — not a reasoned guess, but ~25 hours of actual work, with every real wrong turn and correction included rather than smoothed over.