Skip to content

Gate the Blazor UI with OAuth sign-in (Google) - #536

Merged
rockfordlhotka merged 2 commits into
mainfrom
feature/blazor-oauth-signin
Sep 13, 2026
Merged

Gate the Blazor UI with OAuth sign-in (Google)#536
rockfordlhotka merged 2 commits into
mainfrom
feature/blazor-oauth-signin

Conversation

@rockfordlhotka

@rockfordlhotka rockfordlhotka commented Aug 28, 2026

Copy link
Copy Markdown
Member

Second of two PRs for #534. #535 (the persistent data-protection key ring this depends on) is merged, and this branch has been rebased onto main — the diff below is the auth work only.

Live-tested against a real Google OAuth client. The draft hold was for the completed sign-in round-trip; that now passes. See Live test with a real Google client below for what was and was not exercised.

Why

The Blazor UI has no authentication of its own. The only thing between the internet and a full agent chat session is the network: the chart exposes the UI through the Tailscale operator and tailnet ACLs do the gating. That works well for the primary instance, but it forces every deployer to adopt Tailscale. The trees deployment is the motivating case — it should be able to sit behind an ordinary HTTPS ingress and be secured with Google OAuth instead.

Auth:Enabled defaults to false. Existing deployments behave exactly as they do now: no login page, no cookie, every route anonymous.

Scope

A doorman, not a tenancy model. SessionId/UserId stay constants — everyone who signs in still shares one conversation with the agent. We are deciding who gets in, not whose conversation this is. Per-user sessions would mean reworking agent-side history keying and working-memory namespacing.

The gate lives in the app, not in front of it. An oauth2-proxy sidecar would need no app code, but it only exists in Kubernetes — nothing for compose, nothing for dotnet run, and a different login story per deployment target.

Google first, built as a list. Every seam — config binding, handler registration, the login page, the remembered-provider key — reads AuthProviderRegistry. Adding Microsoft or GitHub is a package reference, a descriptor entry, and one case in the registration switch.

Fail closed, loudly, at startup

Two validation rules throw before the app serves anything:

  1. Auth:Enabled=true with no configured provider.
  2. Auth:Enabled=true with both AllowedEmails and AllowedDomains empty.

Rule 2 is the important one. "Sign in with Google" with an empty allowlist does not mean "my users can get in" — it means every Google account on Earth can get in. That is a misconfiguration, never a default, so the app refuses to start rather than come up wide open. The chart refuses to render the same combination, to save you a CrashLoopBackOff.

Allowlist semantics

UserAllowlist is a pure, testable class:

  • AllowedEmails matches the whole address, case-insensitively.
  • AllowedDomains matches the part after the final @, exactly. A suffix match would let evil-example.com satisfy an example.com rule.
  • An address Google reports as unverified never matches, on either list. email_verified is not mapped by the ASP.NET Core Google handler by default, so it is mapped explicitly — otherwise this check has no claim to read and silently degrades to trusting whatever address came back.

The check lives in an authorization handler, not the OAuth ticket callback, so it runs on every request: removing someone takes effect on their next request rather than whenever their cookie happens to expire. A RevalidatingServerAuthenticationStateProvider (30 min) covers the gap a live SignalR circuit would otherwise leave — a circuit outlives the request that created it, so without this, revoking access does nothing until the tab is closed.

Two things that would otherwise bite

/attachments had no authorization at all. It serves bytes off the shared PVC. Left anonymous it is a side door around sign-in entirely, however well the chat page is locked down. It now carries .RequireAuthorization().

The reverse-proxy failure mode. Behind a TLS-terminating ingress the pod still sees plain http on :8080, so ASP.NET Core builds an http:// redirect_uri, and Google — which accepts https for everything except http://localhost — rejects it with a bare redirect_uri_mismatch. Auth:PublicBaseUrl pins the external origin for every absolute URL the app builds, and needs no trust in any header. Auth:TrustForwardedHeaders is the fallback for multi-hostname deployments and is off by default.

Pipeline shape

Authorization services register whether or not sign-in is enabled, with a permissive default policy when it is off. [Authorize] and .RequireAuthorization() then stay on the same endpoints unconditionally, and turning Auth:Enabled off makes them pass rather than makes them disappear — there is no second, untested arrangement of the pipeline to get wrong.

Explicit attributes, never a global FallbackPolicy: a fallback also covers the MapStaticAssets() endpoints, so anonymous users get 302s for the CSS and blazor.web.js and the login page renders broken.

One consequence worth knowing, because it is counterintuitive: with auth off, AuthorizeView succeeds for anonymous visitors. Both AuthorizeView sites therefore check IsAuthenticated explicitly — without it a "Sign out" button rendered on the open UI, posting to an endpoint that is not even mapped. There is a regression test.

UI

  • /login and /access-denied are static SSR, so an anonymous visitor never allocates a SignalR circuit. The render mode moves off <Routes> onto Chat.razor to make that possible.
  • Providers are plain links, so the page works with JS disabled. login.js only hoists the last-used provider and labels it — it deliberately does not auto-redirect: that makes the page un-exitable when you need to switch accounts, and turns a denied account into a loop you cannot escape from the UI.
  • /access-denied names the account you signed in as. The overwhelmingly likely cause is the wrong Google account, and a bare 403 leaves you unable to tell which one. The session is left intact until you press sign out — there would be nothing to name otherwise.

Deployment

  • A generic HTTPS ingress. templates/blazor/ingress.yaml hardcodes ingressClassName: tailscale, so an OAuth deployer had no way in and the feature had no deployment story. The new one takes a configurable class and pass-through annotations (cert-manager). It refuses to render alongside the tailnet ingress — two Ingresses on one Service with different classes is a coin toss — and refuses to render with auth disabled unless you explicitly say something outside the chart is gating.
  • Probes move to a new anonymous /healthz. With sign-in on, / answers 302 to /login, which a kubelet scores as success — the probe would pass without asserting anything.
  • Compose gets the Auth__* env and a documented .env.example block for the http://localhost:8080 client, the one case Google permits over plain http.
  • Docs: deploy/blazor-oauth-setup.md (Google Cloud Console walkthrough, troubleshooting, verification checklist) and an Authentication section in docs/blazor-ui.md.

Verification

Full suite green (20 projects). 109 tests in the Blazor project, including WebApplicationFactory smoke tests over the real pipeline.

Exercised in a container against a dummy Google client:

Check Result
GET / signed out 302 to /login?returnUrl=%2F
GET /attachments?file=x signed out 302 to /login (not 200)
GET /healthz 200, signed in or not
GET /auth/challenge?provider=Google 302 to accounts.google.com, redirect_uri=http://localhost:8080/signin-google
...with an off-site returnUrl attacker host does not survive into the state
...with an unknown provider 302 to /login, not a 500
Auth on, empty allowlist container refuses to start, with the message explaining why
Auth off / 200, /attachments 404 as before, /auth/* unmapped, no sign-out control

Chart guard rails all fire: empty allowlist, missing client id, missing client secret, both ingresses enabled, generic ingress without auth.

Live test with a real Google client

Rebased onto main at 4f04133 (#567). Full suite re-run green on the rebased branch (20 projects, 109 Blazor tests), and the chart re-rendered with auth off, auth on, and both guard rails.

Then the compose stack was built from this branch with a real Google OAuth client (External, Testing status, redirect URI http://localhost:8080/signin-google) and the deploy/blazor-oauth-setup.md section 5 checklist run against it:

Check Result
GET / signed out ✅ 302 to /login?returnUrl=%2F
GET /attachments?file=x signed out ✅ 302 to /login
GET /healthz ✅ 200
Challenge carries the real client id, redirect_uri=http://localhost:8080/signin-google, openid profile email
Sign in with an allowlisted account ✅ chat page loads, "Sign out" in the header
Restart the Blazor container, reload ✅ still signed in (key ring on the volume)
Close the browser, reopen ✅ still signed in (persistent cookie)

Still not exercised:

  • /access-denied with a real non-allowlisted account. Needs a second Google account that is a Google test user but not on the RockBot allowlist. Covered by the allowlist unit tests and the smoke tests, not by a live sign-in.
  • An HTTPS ingress deployment. The live test ran over http://localhost, so the publicBaseUrl-behind-a-TLS-proxy path has only the unit and chart coverage.

The test turned up drift in the setup doc, fixed in c3455d4: Google moved OAuth configuration into Google Auth Platform, client secrets are now shown only at creation, and Google enforces its own test-user list before RockBot sees the sign-in — so an account must be on both lists, and testing /access-denied needs a test user deliberately left off the RockBot allowlist.

Unrelated to this PR, the same stack showed the agent image built from current main failing to start; that is a separate fix.

Closes #534.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FieTYKLKEbarXNyPrHBX9F

Base automatically changed from feature/blazor-data-protection to main August 28, 2026 04:36
@rockfordlhotka
rockfordlhotka force-pushed the feature/blazor-oauth-signin branch from d1ae313 to dde3c14 Compare August 28, 2026 04:36
@rockfordlhotka
rockfordlhotka marked this pull request as draft August 28, 2026 04:37
The UI has never authenticated anybody. The only thing between the internet and a
full agent chat session is the network: the chart exposes it through the Tailscale
operator and tailnet ACLs do the gating. That works, but it forces every deployer
to adopt Tailscale — trees should be able to sit behind an ordinary HTTPS ingress
instead.

Auth:Enabled defaults to false and every existing deployment keeps behaving
exactly as it does today.

This is a doorman, not a tenancy model. SessionId and UserId stay constants;
everyone who signs in still shares one conversation with the agent. We are
deciding who gets in, not whose conversation this is.

Fail closed, loudly, at startup. Enabled with no provider throws; enabled with an
empty allowlist throws. The second rule is the important one — "sign in with
Google" with nothing listed does not mean "my users can get in", it means every
Google account on Earth can get in, so it is a startup failure rather than an open
door. The chart refuses to render that combination too, to save a CrashLoopBackOff.

Allowlist matching is a pure, testable class: whole-address match case-
insensitively, domain matched exactly against the part after the final '@' so
evil-example.com cannot satisfy an example.com rule, and an address Google reports
as unverified never matches. The check lives in an authorization handler rather
than the OAuth ticket callback so it runs on every request, and a 30-minute
circuit revalidation tears down a live SignalR circuit whose user has been removed
— without it, revoking access does nothing until the tab is closed.

Two things that would otherwise bite:

- /attachments served PVC bytes with no authorization at all. Left alone it is a
  side door around sign-in entirely, so it gets .RequireAuthorization().
- Behind a TLS-terminating ingress the pod sees plain http on :8080, so the OAuth
  redirect_uri comes out as http:// and Google rejects it with a bare
  redirect_uri_mismatch. Auth:PublicBaseUrl pins the external origin for every
  absolute URL the app builds, and needs no trust in any forwarded header.

Authorization is registered whether or not sign-in is on, with a permissive
default policy when it is off, so [Authorize] and .RequireAuthorization() stay on
the same endpoints unconditionally — there is no second arrangement of the
pipeline that only runs in the disabled case. Explicit attributes rather than a
FallbackPolicy: a fallback also covers MapStaticAssets, and the login page would
render unstyled with blazor.web.js 302'ing to itself.

/login and /access-denied are static SSR, so an anonymous visitor never allocates
a circuit; the render mode moves off <Routes> onto Chat.razor for that. The
remembered-provider script reorders buttons and never auto-redirects, because a
bounce loop on a denied account is unescapable from the UI. /access-denied names
the account you signed in as, since the likely cause is the wrong Google account.

Helm gets a generic HTTPS ingress — the existing one hardcodes
ingressClassName: tailscale, so an OAuth deployer had no way in. It refuses to
render alongside the tailnet ingress, or without auth unless you explicitly say
something else is gating. Probes move to a new anonymous /healthz: with sign-in on
/ answers 302 to /login, which a kubelet scores as success, so the probe would
pass without asserting anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FieTYKLKEbarXNyPrHBX9F
@rockfordlhotka
rockfordlhotka force-pushed the feature/blazor-oauth-signin branch from dde3c14 to 187ceb5 Compare September 13, 2026 22:00
Google moved OAuth configuration into Google Auth Platform (Branding,
Audience, Clients, Data Access), so the walkthrough's "OAuth consent
screen" and "Credentials" steps no longer match the console.

Found while running the live sign-in test against a real client:

- Client secrets are shown only at creation now; lost ones must be
  replaced, not recovered.
- Google enforces the test-user list before RockBot sees the sign-in,
  so an account must be on both lists, and testing /access-denied needs
  a test user deliberately left off the RockBot allowlist.
- A new redirect URI can take minutes to propagate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SydqNVaaq48Q2fpPZWesJ
@rockfordlhotka
rockfordlhotka marked this pull request as ready for review September 13, 2026 22:39
@rockfordlhotka
rockfordlhotka merged commit 214df1d into main Sep 13, 2026
1 check passed
@rockfordlhotka
rockfordlhotka deleted the feature/blazor-oauth-signin branch September 13, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Secure the Blazor UI with OAuth sign-in (Google first) as an alternative to Tailscale

1 participant