An Angular + .NET Auth Template with Multi-Channel 2FA and a Live Demo
Most auth starter templates ask you to take them on faith. The README promises login, 2FA, and password reset, and you find out whether any of it works after you've cloned it and spent an evening wiring it up. angular-dotnet-auth-template is my attempt to do better: an Angular 21 + .NET 10 starter with local auth and multi-channel 2FA (authenticator/TOTP, email, SMS) out of the box, and a live demo running on Cloud Run. Register an account, set up authenticator 2FA, reset a password. It's the same code the template ships, deployed by the same pipeline the template includes, running entirely on free tiers. Here's what's in it and the decisions behind it. What's in the box The stack is deliberately current: standalone Angular components with zoneless change detection, .NET 10, a Docker-based local dev environment, xUnit integration tests against the API via WebApplicationFactory , Vitest for the client, and a full Playwright e2e suite covering the auth flows. It's a proper GitHub template, following the same conventions as my NuGet release template: click "Use this template," then $ grep -rn "TODO(template)" . gives you the complete customization checklist, deploy workflow included. The interesting logic doesn't live in the template Templates rot when their interesting logic is baked into scaffolded code, because every generated copy forks it permanently. So the two chunks worth reusing became NuGet packages the template consumes like any other dependency: - DGates.Identity.NotificationProviders :IEmailSender andISmsSender implementations for SendGrid, Postmark, Twilio, and AWS SNS - DGates.Identity.Jwt2Fa : the JWT issuance and 2FA orchestration logic behind anIAuthCoreService abstraction Generated projects pick up fixes with a package update instead of a manual diff. The template itself is the first production consumer of both packages, which is a nicer proof than any sample app. Local development doesn't need real messaging-service accounts either. Mock servers for SendGrid, Postmark, and Twilio live in dgates-mock-servers, published as public GHCR images so generated repos can pull them with zero auth setup. Keeping the demo honest A live demo is only useful evidence if it's running the same thing the template ships. So the demo runs the template's real stack, MySQL included, rather than swapping in SQLite or an in-memory database to make deployment easier. And when a platform constraint forced a config change (more on that below), the change became the real default everywhere, local dev included, rather than a demo-only fork. The same goes for the verification flows. Registration and email/SMS 2FA run for real, just against the mock providers, and the demo banner links straight to both mock inboxes so you can find your code. Free means free The other constraint was a zero-dollar bill, not "free for 90 days." That ruled out Cloud SQL despite an available trial credit; trial credits expire, and migrating off a paid tier later is worse than starting genuinely free. The demo runs on Aiven's free MySQL tier instead, with an automated power-on-and-poll step in the deploy workflow to handle its idle shutdown. Free extends to the providers, too. The demo's email and SMS traffic goes to the same mock servers that back local dev, deployed as their own scale-to-zero Cloud Run services. No paid SendGrid or Twilio account sits behind the demo, no real SMS ever gets sent, and the mocks cost nothing while idle. The mock-server images doing double duty here, local dev and live demo from the same GHCR images, is exactly why they live in their own repo. docs/DEPLOYMENT.md documents the whole setup, including where the free-tier ceiling actually is. Nothing long-lived to leak Same philosophy as the release template's Trusted Publishing: no static credentials anywhere. Deploy auth uses Workload Identity Federation rather than a service account key, with the WIF provider's attribute condition locked to this exact repository: assertion.repository == 'dgates82/angular-dotnet-auth-template' I verified the fork-safety story rather than assuming it: a generated copy's OIDC token carries its own repository claim, which fails that condition, so a repo generated from the template can't accidentally deploy into my project. GitHub also doesn't copy secrets to generated repos, so a copied workflow fails even earlier. Both layers checked, not just reasoned about. What only a real deploy will tell you No amount of local testing substitutes for the first real end-to-end deploy. Three finds worth sharing: Cloud Run proxies a single HTTP port per service. The template's default email flow used Mailpit over SMTP for local dev, and that could never work on Cloud Run; there is no second port on which to reach an SMTP listener. Rather than special-casing the demo, I switched the default email provider to SendGrid everywhere, local dev included, so the demo keeps faithfully representing what the template ships. A genuine platform constraint is a better reason to change a default than a preference. Connection strings can be set too late. Overriding the connection at the EF Core options level didn't work, because ServerVersion.AutoDetect had already opened a connection with the original string. The fix was supplying ConnectionStrings__DefaultConnection as an environment variable so the right value is there from the start. Testing against local MySQL would have masked this completely. The Docker image was shipping a dev Angular build. Nobody notices locally because dev builds work fine; they're just bigger and slower. Fixed properly in angular.json 's defaultConfiguration , not patched in the Dockerfile. There were more (a seed admin user that silently never got created, password-reset emails linking to localhost ), and each one became a config fix, a TODO(template) marker, or better docs. The flaky test that lied to me The template ships a full Playwright e2e suite that exercises every auth workflow in a real browser: registration, login, password reset, and each 2FA channel end to end, against the mock providers. It runs in CI, which is exactly what you want from an auth template - and it's also how I noticed that one test kept failing. CI history showed it was always the same test, same browser, same assertion, failing identically on retry too. That consistency meant a real bug, not noise. My first theory was reasonable: TOTP codes are only valid for about 30 seconds, so a code generated near the end of a window could expire before the server processed it under CI latency. I added a check on the remaining window and shipped it. Then I kept stress-testing instead of declaring victory, and it still failed 3 times in 14 runs. The real answer came from reading an actual failure artifact instead of re-running blindly. Playwright's error context showed the server genuinely rejecting the code, which points to "wrong secret," not "expired code." The component renders the shared secret asynchronously, starting as an empty string, and locator.textContent() only waits for the element to attach, not for its content. Under load, the test was generating TOTP codes from an empty secret. The fix was one line: await expect(sharedKey).not.toHaveText(''); Result: 20 clean runs out of 20, versus 3 failures in 14 with only the timing fix. This was a good reminder to slow down when a test goes flaky. Reproduce it, read the actual failure artifact, and don't stop at the first theory that fits. "It passed once after my fix" proves nothing. Use it Click "Use this template" on angular-dotnet-auth-template, grep for TODO(template) , and work the list. Or just poke at the live demo first: registration is open, so create an account and try the 2FA flows yourself. The test that mattered for this one wasn't the e2e suite. It was whether the thing could be deployed, for free, by its own included pipeline, and stay honest about what it ships while doing it. If you generate a project from it and hit something the markers or docs didn't cover, open an issue - that's a gap in the template, and I'd like to close it. Top comments (0)
Comments
No comments yet. Start the discussion.