Gate the Blazor UI with OAuth sign-in (Google) - #536
Merged
Conversation
rockfordlhotka
force-pushed
the
feature/blazor-oauth-signin
branch
from
August 28, 2026 04:36
d1ae313 to
dde3c14
Compare
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
force-pushed
the
feature/blazor-oauth-signin
branch
from
September 13, 2026 22:00
dde3c14 to
187ceb5
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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
treesdeployment is the motivating case — it should be able to sit behind an ordinary HTTPS ingress and be secured with Google OAuth instead.Auth:Enableddefaults tofalse. Existing deployments behave exactly as they do now: no login page, no cookie, every route anonymous.Scope
A doorman, not a tenancy model.
SessionId/UserIdstay 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-proxysidecar would need no app code, but it only exists in Kubernetes — nothing for compose, nothing fordotnet 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:
Auth:Enabled=truewith no configured provider.Auth:Enabled=truewith bothAllowedEmailsandAllowedDomainsempty.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
UserAllowlistis a pure, testable class:AllowedEmailsmatches the whole address, case-insensitively.AllowedDomainsmatches the part after the final@, exactly. A suffix match would letevil-example.comsatisfy anexample.comrule.email_verifiedis 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
/attachmentshad 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 anhttp://redirect_uri, and Google — which accepts https for everything excepthttp://localhost— rejects it with a bareredirect_uri_mismatch.Auth:PublicBaseUrlpins the external origin for every absolute URL the app builds, and needs no trust in any header.Auth:TrustForwardedHeadersis 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 turningAuth:Enabledoff 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 theMapStaticAssets()endpoints, so anonymous users get 302s for the CSS andblazor.web.jsand the login page renders broken.One consequence worth knowing, because it is counterintuitive: with auth off,
AuthorizeViewsucceeds for anonymous visitors. BothAuthorizeViewsites therefore checkIsAuthenticatedexplicitly — 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
/loginand/access-deniedare static SSR, so an anonymous visitor never allocates a SignalR circuit. The render mode moves off<Routes>ontoChat.razorto make that possible.login.jsonly 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-deniednames 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
templates/blazor/ingress.yamlhardcodesingressClassName: 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./healthz. With sign-in on,/answers 302 to/login, which a kubelet scores as success — the probe would pass without asserting anything.Auth__*env and a documented.env.exampleblock for thehttp://localhost:8080client, the one case Google permits over plain http.deploy/blazor-oauth-setup.md(Google Cloud Console walkthrough, troubleshooting, verification checklist) and an Authentication section indocs/blazor-ui.md.Verification
Full suite green (20 projects). 109 tests in the Blazor project, including
WebApplicationFactorysmoke tests over the real pipeline.Exercised in a container against a dummy Google client:
GET /signed out/login?returnUrl=%2FGET /attachments?file=xsigned out/login(not 200)GET /healthzGET /auth/challenge?provider=Googleaccounts.google.com,redirect_uri=http://localhost:8080/signin-googlereturnUrl/login, not a 500/200,/attachments404 as before,/auth/*unmapped, no sign-out controlChart 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
mainat 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 thedeploy/blazor-oauth-setup.mdsection 5 checklist run against it:GET /signed out/login?returnUrl=%2FGET /attachments?file=xsigned out/loginGET /healthzredirect_uri=http://localhost:8080/signin-google,openid profile emailStill not exercised:
/access-deniedwith 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.http://localhost, so thepublicBaseUrl-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-deniedneeds a test user deliberately left off the RockBot allowlist.Unrelated to this PR, the same stack showed the agent image built from current
mainfailing to start; that is a separate fix.Closes #534.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FieTYKLKEbarXNyPrHBX9F