Skip to content

Central authorization: access control plane for proxied webapps (Phase 1) - #17

Merged
swimmesberger merged 19 commits into
mainfrom
wt/watchtower-central-auth-84057b
Aug 10, 2026
Merged

Central authorization: access control plane for proxied webapps (Phase 1)#17
swimmesberger merged 19 commits into
mainfrom
wt/watchtower-central-auth-84057b

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

Central Authorization — Access Control Plane for Proxied Webapps (Phase 1)

Turns Watchtower into a Cloudflare-Access-style access-control plane for the apps it proxies, while also giving Watchtower's own UI a real login. Opt-in via WATCHTOWER__AUTH__ENABLED (default off), so existing deployments are unchanged until enabled.

Design: docs/central-auth/design.md · Operator guide: docs/central-auth/README.md

What's included

  • Identity & sessions — local users on ASP.NET Identity core over the existing SQLite; revocable DB-backed sessions (delete row = signed out), lockout, first-run admin bootstrap, and a break-glass recovery path (WATCHTOWER__AUTH__RESETPASSWORD + published-port fallback).
  • Watchtower as protected app #0 — retires the AnonymousCurrentUser placeholder; [assembly: ElarionAuthorizationDefaults] turns on secure-by-default across every handler, with an implicit-admin fallback that keeps Auth:Enabled=false behavior byte-identical to today.
  • Users module + admin UIusers.* handlers ([RequireRole("Admin")]), last-admin guard, session revocation on password reset / disable.
  • Forward-auth core — verify endpoint, cross-domain login dance with single-use codes, ES256 JWT + JWKS, and protected Caddy site blocks that strip inbound identity headers before trusting the verified ones.
  • Per-app access policy — Public / Authenticated / Restricted + bypass paths, set from Routes → Access.
  • Hardening & docs — login rate limiting (layered on Identity lockout), audit trail (AuthEvent), operator guide, and a fix for the previously-vacuous CI schema-freshness gate (now proven to catch drift).

Not in this PR (Phase 2, scoped in the design doc)

OIDC/Keycloak upstream, groups, MFA, an audit-viewing UI.

Verification

  • dotnet build Watchtower.slnx -c Release — clean (0 warnings, TreatWarningsAsErrors on)
  • dotnet test Watchtower.slnx208 passed / 0 failed (this feature introduces the repo's first test projects: Watchtower.Application.Tests, Watchtower.Api.Tests)
  • ef migrations has-pending-model-changes — clean; exactly one new migration (AddCentralAuth)
  • RPC schema byte-identical to code (72 methods); frontend typechecks and builds
  • CI schema-drift gate fixed and live-proven to fail on real drift

~12,200 insertions across 95 files. Every commit went through an independent review loop before merge.

Reviewer notes

  • Backend-heavy; the SPA Access dialog and login flow are covered by backend + build/typecheck but not yet browser-smoke-tested with Auth:Enabled=true — worth a manual pass.
  • Known limitations documented in the operator guide (cookie-tossing residual, /.watchtower/* reserved prefix, verify reachable on the published port).

Adds the Users module from docs/central-auth/design.md §7: users.list /
create / update / resetPassword / setDisabled / delete, every handler
[RequireRole("Admin")], plus the matching admin screen in the SPA.

Backend (src/Watchtower.Application/Modules/Users):
- UserDto never carries the password hash or the security/concurrency
  stamps; LockedOut is derived from LockoutEnd against the clock, because
  a lockout lapses and a stored flag would be stale the moment it did.
- Passwords go through UserManager (policy + PBKDF2); resetPassword uses a
  reset token, so a policy-violating value leaves the previous password
  working instead of clearing it.
- Last-admin guard on demotion, disable and delete — the only refusal in
  the module. Self-demotion, self-disable and self-delete stay allowed.
- resetPassword, setDisabled(true) and delete revoke the account's
  sessions; re-enabling also clears the brute-force lockout.
- AuthEvent rows for create/update/delete/password reset/disable/enable,
  naming the target in Detail because the FK is SET NULL on delete.

Frontend (src/watchtower-web/src/modules/users): gated Users page with the
create/edit/set-password dialogs, enable-disable and delete confirmations,
and RPC failure messages surfaced as toasts.

rpc-schema.json regenerated (70 methods, purely additive).
…ed Caddy sites, JWT (WI-4)

Implements docs/central-auth/design.md §5 and §6: Caddy forward-auths every
request to a protected app to GET /api/access/verify, which resolves the route
by X-Forwarded-Host, honours bypass paths, validates the per-app __wt_access
session and answers with a verdict — 200 plus identity headers, a 302 to the
central login page for browser navigations, a plain 401 for everything else, or
a 403 denial page when a Restricted route holds no grant for the account.

The cross-domain hand-over: /api/auth/login gained an optional redirectUri and
/api/auth/continue is the same step for a visitor already signed in centrally.
Both validate the URL against the route table, check authorization, and mint a
single-use 60-second login code; /.watchtower/callback on the app's own domain
redeems it into that domain's session, and /.watchtower/logout gives it back.

Identity forwarding is a trust boundary (§2.3), so protected site blocks strip
X-Watchtower-User/-Email/-Jwt from the inbound request before forward_auth adds
the verified ones, and the ES256 assertion binds `aud` to the app's own domain.
The key pair is generated on first use, persisted as PEM under Auth:KeyPath, and
published as a JWKS at /api/auth/jwks.

No schema change: LoginCode and RouteAccessGrant already exist from WI-1.
…ut, callback host check

1. [MAJOR] RouteAccessPolicy.IsExemptPath treated /webhooks/..%2fadmin (and
   %2F/%5c/%5C, %2e variants) as exempt: the dots stay literal so no segment
   equals "..", while the encoded separator hides the traversal, so an upstream
   that decodes %2f→/ and normalises reaches /admin unauthenticated. Any
   percent-encoding in the matched path now disqualifies the fast-path
   exemption — a literal ASCII bypass prefix needs no encoding to be matched, so
   an encoded byte is only ever an attempt to smuggle past the prefix check.

2. [MINOR] CaddyManager.ProjectSites: an explicit Route for Auth:Host set
   Authenticated/Restricted was emitted behind forward_auth → login-redirect
   loop, UI reachable only via the published port. The auth-host site is now
   force-unprotected whether it comes from an explicit row or the synthesised
   self-route; the explicit row still renders its own upstream.

3. [MINOR] Callback host binding was skipped when X-Forwarded-Host was absent.
   Caddy always sets it for a request through the app site, so absence now
   refuses rather than minting a cookie scoped to an unbound host.

Tests: encoded-separator vectors asserted EXEMPT=false, a plain path still
matches; explicit non-Public auth-host row asserted unprotected; callback
without X-Forwarded-Host asserted refused.
… role gating

- Everything after an account mutation commits now runs on
  CancellationToken.None: session revocations, the lockout clear and the
  audit save. On the request token a caller hanging up mid-request could
  leave a reset password with its old sessions live, or an administrative
  action out of the trail. RecordAsync takes no token at all, so the
  invariant is structural rather than remembered — same mechanism the
  login endpoints already use.

- DeleteUser is delete-then-audit. There is no ambient transaction across
  the two writes, so auditing first meant a delete that failed on the
  concurrency stamp left a trail claiming an account was removed while it
  is still there. Sessions now go through the verified FK cascade instead
  of an explicit pre-revoke that a failed delete would leave applied.

- Last-admin guard stays a pre-check, and its doc no longer overclaims.
  Evidence: Elarion's TransactionDecorator does roll back on a failed
  Result, but it is opt-in on three counts this app does not meet — no
  [DecoratorList], no ICommand-marked requests, and no AddElarionUnitOfWork
  (the default IUnitOfWork is the no-op InMemoryUnitOfWork). So a failed
  Result rolls nothing back here and an after-the-write re-check could not
  close the race. Documented, with the break-glass env var as recovery.

- IdentityResult mapping is consistent: a concurrency failure is Conflict
  in every handler, everything else Validation. Detected by Identity's
  error code read off IdentityErrorDescriber, never by message text.

- setDisabled clears the lockout in the same write as the flag (one write,
  not three) and its result is checked, so the audit no longer claims
  lockoutCleared unconditionally. resetPassword checks it too.

- Frontend gates the Users module and route on module AND role 'Admin'
  (design.md §8) by wiring the contributions kit's role axis in
  vocabulary.ts. No generated code was forked; the backend exports no role
  catalogue, so RoleName is `string` and that caveat is documented.

- AuthEventKinds holds the shared Kind vocabulary, used by both this module
  and the login endpoints; RouteId is stated explicitly on audit rows.

Tests: non-admin denial for all five mutations, and a delete that loses the
concurrency race writes no audit row and returns Conflict.
# Conflicts:
#	src/Watchtower.Api/Endpoints/WatchtowerAuthEndpoints.cs
@swimmesberger

Copy link
Copy Markdown
Owner Author

Follow-up pushed: identity-forwarding redesign (WI-8)

Three commits added on top of the Phase 1 work (aa9920e, 2697364, 897f3d3), reworking how identity reaches upstreams — prompted by review discussion that the bespoke X-Watchtower-User/-Email convenience headers were the worst of both worlds (spoofing surface, but no off-the-shelf app recognizes the names).

What changed

  • Per-route identity-header mode, JWT-only by default. Route.IdentityHeaderMode = None (default — forwards only the signed X-Watchtower-Jwt) / Remote (Authelia/Traefik Remote-*) / AuthRequest (oauth2-proxy X-Auth-Request-*), set from the Routes → Access dialog. The bespoke X-Watchtower-User/-Email names are gone; opt-in headers use names the ecosystem already reads.
  • Defense-in-depth strip set. The proxy now strips the full Authelia + oauth2-proxy identity/authz header namespace on every protected route (all modes) — a superset of what it forwards, including Remote-Groups / X-Auth-Request-Groups / X-Auth-Request-Access-Token / the X-Forwarded-* identity family — so a client can't forge Remote-Groups: admins past the gate. Transport X-Forwarded-For/-Proto/-Host are deliberately preserved.
  • OIDC UserInfo endpoint (OpenID Connect Core §5.3): GET /api/access/userinfo (+ /.watchtower/userinfo on app domains) returns standard claim JSON (sub, preferred_username, email, roles) for a Bearer JWT or the __wt_access cookie. Adds ES256 token validation to AuthTokenSigner (alg-pinned, exp/iss enforced, alg-confusion rejected) with a fresh user reload so disabled accounts are refused immediately.
  • Docs (design §2.3/§5/§6 + operator guide) reconciled to the new shape.

Review: went through an adversarial security review that built real token-confusion attacks (alg:none, HS256-with-public-key, expired, wrong-issuer — all rejected) and caught one privilege-escalation MAJOR — forged group headers surviving to the upstream — which the strip-set redesign above fixes; re-review confirmed it closed.

Verification: build clean, 247 tests (was 208), migrations clean (one new: AddRouteIdentityHeaderMode), rpc-schema regenerated (the two access handlers gain the mode field, additive/non-breaking), frontend typechecks + builds.

The reviewer note about a browser smoke-test under Auth:Enabled=true still stands and now also covers the Access dialog's new "Identity forwarding" selector.

…-auth-84057b

# Conflicts:
#	README.md
#	src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs
@swimmesberger
swimmesberger merged commit 8c44943 into main Aug 10, 2026
2 checks passed
@swimmesberger
swimmesberger deleted the wt/watchtower-central-auth-84057b branch August 10, 2026 08:24
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.

1 participant