Pivots, Pipelines, and the First Real Deploy: Chicome's Kitchen Dev Log #2

Share

Last time, the Flutter scaffold was running on all three flavors and the "login" button just flipped a boolean. Since then: a frontend architecture pivot, the entire auth backend, a real Blazor web client talking to it, an infrastructure reconciliation effort that turned up more drift than expected, and — for the first time — actual application code running in Azure instead of just on localhost. Here's the whole arc.

Dropping Flutter Web for Blazor

Before writing more code, I revisited a decision from the original plan: Flutter Web as the browser experience. The problems stacked up — Flutter Web's canvas renderer hurts SEO and accessibility, the tooling for form-heavy UIs is thinner than native Flutter's, and the backend is already 100% C#/.NET. Blazor WASM closes that gap entirely: same language, same types, same model contracts as the API, no duplicated DTOs.

So Flutter Web is gone. Flutter stays for iOS/Android only; Blazor WASM takes over the browser. Every [UI] backlog item got split into a [Web] Blazor story and a [Mobile] Flutter story with platform-appropriate acceptance criteria, the PMP got updated, and the ADO import got regenerated with the full split applied. Web and mobile still ship together per phase — no separate launches.

Building Out the Auth Backend

With the architecture settled, it was time to build the thing the Flutter scaffold's login button was faking. Five endpoints landed back to back: login, refresh, email verification, logout, and Google OAuth.

Login uses RS256 (asymmetric) JWTs rather than HS256 — the private key signs, the public key verifies, which sets up nicely for a future JWKS endpoint if other services ever need to verify tokens independently. Refresh tokens are 256 bits of random entropy, base64-encoded for the client, but only the SHA-256 hash ever touches the database. The login endpoint is also deliberately boring about failure: invalid credentials and "email not verified" return the same response shape, and a nonexistent email looks identical to a wrong password, so the endpoint can't be used to enumerate registered accounts.

Refresh introduced token rotation and theft detection: every successful refresh revokes the presented token and issues a new one in the same family. If a revoked token ever gets presented again — a replayed, stolen token — the whole family gets revoked, forcing a fresh login. The one snag here was sneaky: two integration tests failed with 401 on the very first refresh call after login, and it turned out WebApplicationFactory's default http://localhost base address meant CookieContainer was silently refusing to send back the Secure-flagged refresh cookie. The in-memory test server doesn't care about scheme, but CookieContainer absolutely does — giving the test client an https:// base address fixed it.

Email verification turned out to be the smoothest story of the bunch — leaning on ASP.NET Identity's existing token provider instead of building a custom HMAC scheme meant the confirm-email endpoint just worked, clean build and all tests green on the first run. Logout followed the same pattern as refresh's theft detection (revoke the whole token family, not just one token) and is unconditionally idempotent — calling it while already logged out still returns 204 instead of erroring.

Google OAuth was the most involved of the five, and also the one that came back to bite later. Google.Apis.Auth's GoogleJsonWebSignature.ValidateAsync handles signature/expiry/tampering validation without a network round-trip, and an IGoogleTokenValidator abstraction kept the handler testable without hitting Google's servers at all. Three cases fall out of the handler: brand-new Google user, returning Google user, or an existing password-based account whose email matches — in which case the accounts get linked. All three end with the same token shape as a normal login.

Wiring the Frontend to the API — and Finding the Cracks

Story 213 didn't exist in the original backlog. It got backfilled mid-sprint after realizing the Blazor Register/Login pages had been built and looked right, but nobody had ever actually pointed them at a running API. Once that testing actually started, it surfaced a small pile of things that "looks right" had been hiding:

  1. CORS landed on the wrong project first. Added the Cors config block to the Blazor app's appsettings.Development.json instead of the API's — CORS is enforced server-side, so this did precisely nothing. Caught on review, moved to the right place.
  2. The API was never actually running. The solution's startup project was set to the Web app only, so every ERR_CONNECTION_REFUSED was just... nothing listening on that port. Fixed via Solution Properties → multiple startup projects.
  3. The Google button was wired to a flow that never existed. It tried to navigate to a server-redirect endpoint that was never built — Story 18 implemented the client-driven flow (POST /api/auth/google with an ID token), not a redirect flow. Worse, the validator wasn't checking the token's audience at all, meaning it would've accepted a valid Google ID token minted for any app, not just this one. Both issues got deferred and became the reason Story 18 got reopened.
  4. Small route mismatches everywhere. Login redirected to /home, which didn't exist (Home.razor is routed at /). Registration redirected to /check-your-email, but the page was declared at /check-you-email — missing an "r." Both one-line fixes, both the kind of thing that's invisible until you actually click through.
  5. The real find: the HttpOnly refresh-token cookie was never actually being stored by the browser. Blazor WASM's HttpClient sends cross-origin requests with credentials: 'same-origin' by default, and nothing was overriding that — so the cookie the API was dutifully setting on login just evaporated. The CORS policy already allowed credentials server-side; the client just never asked for them. One line (SetBrowserRequestCredentials(BrowserRequestCredential.Include)) fixed refresh, logout, and login all at once.

By the end, the full loop — register, verification email, confirm, sign in, sign out — worked end-to-end against the live Azure dev database. Google sign-in stayed broken on purpose, deferred to the reopened Story 18.

Finishing Google Sign-In for Real

Reopening Story 18 meant actually building the client half of the flow this time: a Google Cloud OAuth Client ID, Google Identity Services' JS library, a small interop wrapper so GIS can hand a credential back to a [JSInvokable] .NET method, and — critically — tightening GoogleTokenValidator to actually check the token's audience against that client ID instead of accepting tokens minted for anybody.

The best bug of this stretch wasn't even a real bug: the first click of the Google button threw TypeError: Failed to execute 'query' on 'Permissions': Illegal invocation, which looked fatal but pointed entirely into Google's own minified code. GIS internally probes navigator.permissions.query() to feature-detect FedCM support, wrapped in its own error handling — but Visual Studio's attached JavaScript debugger intercepted the exception as "unhandled" before GIS's own handling ever got a chance to run. Ctrl+F5 instead of F5 (no debugger attached) and it worked perfectly. A good reminder that "unhandled exception in the debugger" and "broken in production" aren't always the same claim.

Chasing down a way to test any of this against a real deployed environment instead of localhost surfaced the next problem: nothing had ever actually been deployed anywhere.

Reconciling Infrastructure With Reality

The bicep templates for the data tier existed, but the actual Postgres/Redis/Key Vault/Storage resources had been provisioned by hand through the portal months earlier — because some original SKU choices weren't available in the target region — and the templates had quietly drifted out of sync ever since. Reopening Story 90 meant treating the portal as ground truth and working backward:

  • Redis wasn't even the right resource type. The bicep had a classic Microsoft.Cache/redis resource, but what's actually running is Azure Managed Redis (Microsoft.Cache/redisEnterprise) — a different resource entirely, not just a different SKU. Same regional-availability problem that caused the manual provisioning in the first place.
  • Three resources had drifted names (App Service Plan, App Service, Application Insights all had different names in the portal vs. the original bicep) — renamed in code to match what's real.
  • az deployment sub what-if did its job. Running it surfaced two genuine config drifts (Postgres's actual compute size, Key Vault's actual soft-delete retention) and, more seriously, that the Redis and Blob Storage rewrites were missing properties the real resources already had configured — high availability and soft-delete protection would have been silently disabled by an actual deploy. Caught before it mattered, not after.
  • No Communication Services module existed at all, despite two real resources backing the verification-email flow since Story 16. Added one.

Then, before closing the story, a final gut-check against the original acceptance criteria (rather than just the portal-reconciliation work) turned up three real gaps: no Staging/Production parameterization, no GitHub Actions pipeline at all, and no path for secrets into Key Vault. The Staging/Production piece got deferred — not needed yet — but the Dev pipeline got built right then, using GitHub OIDC federation so no Azure credential of any kind lives in GitHub, only non-secret identifiers as repo variables. Three pipeline runs and two real bugs later (a subscription-vs-resource-group scoping mismatch, and a resource-group region conflict that had been dismissed as cosmetic two paragraphs earlier and was very much not), the first automated deployment of anything in this stack went out clean.

Actually Shipping the App

Infrastructure being deployable isn't the same as application code running on it, so Story 214 picked up where 90 left off: real CI/CD for both the API and the Blazor app, then debugging the live environment until everything — register, confirm, login, Google sign-in — actually worked against real Azure resources.

The structural problem worth calling out: every cloud tier (dev, staging, prod) has to report ASPNETCORE_ENVIRONMENT=Production for the app's Managed-Identity logic to kick in correctly, which means ASP.NET Core's own appsettings.{Environment}.json convention can't distinguish a Dev deployment from a Staging or Prod one — there's no environment name for "Production, but the Dev tier." The fix was a parallel set of non-reserved config files (appsettings.Dev.json) that each pipeline copies over the base config at build time, before publish. The same logic applies even harder to the Blazor app once it's published as static files with no dev-server to inject anything.

Then came the secrets. One by one, in this order: Postgres connection string, Redis connection string, Blob Storage connection string, the Communication Services connection string, and the ACS sender address — five secrets that had only ever existed in local dotnet user-secrets and had never once been replicated anywhere the cloud could read them. Each one announced itself as its own crash or 500, and each one got fixed the same way: write it into Key Vault from bicep, grant the App Service's managed identity read access.

The sixth secret — the JWT private key — was the one that actually put up a fight. Three separate theories, in order: JSON-escaped newlines not converting to real ones during copy-paste (fixed by routing the extraction through ConvertFrom-Json), a UTF-8 BOM corrupting the PEM (fixed with explicit ASCII encoding), and finally — after confirming the source variable was a correct, exact 1703 characters moments before upload — the discovery that the verification command itself was lying. az keyvault secret show --query value -o tsv treats embedded newlines as row separators, so it had only ever been capable of showing the first line of a multi-line PEM key (about 27 characters), regardless of whether the actual stored secret was fine or broken. Every previous "only 28 characters!" panic had been a measurement artifact, not necessarily a real failure. Switching to a JSON-based read confirmed the key was correct — and then login still failed for several more minutes anyway, because Key Vault configuration only loads once at process startup; the already-running instance had started before the fix landed and had no reason to know anything had changed. One az webapp restart later, login returned 200 with a real access token.

Last up: Google sign-in, still failing, but with a cleanly different signature than before — a real 401 straight from the API instead of a client-side error, meaning the request was getting through and a validation check was rejecting it. The deployed config simply had no Google:ClientId at all, so every token's audience was being checked against null. Unlike the other six secrets, this one isn't actually secret — Google Client IDs are public and already shipped inside the Blazor bundle — so it went straight into appsettings.Dev.json as plain config instead of another Key Vault round-trip.

Where Things Stand

Register, email verification, login, and logout are all confirmed working end-to-end against the live Azure dev environment — not localhost. The Google sign-in config fix is pushed and just needs one more click-through to call it fully verified. Up next: getting local development off the shared Azure dev database entirely by moving Postgres/Redis/Azurite into Docker and giving integration tests their own isolated database instead of quietly sharing one with whatever's running on a laptop.

More soon.