Table of Contents

Login and Authentication

The sign-in screen is the /login route. Whether anyone ever reaches it is decided by one back-end setting — the Enabled flag of the Auth configuration section: with the flag off the service is open and the route sends every visitor straight back out, and with it on a cookie-authentication scheme plus a global authorization fallback policy lock every hub and every controller but the authentication one — the whole api/ surface apart from api/auth — until a visitor signs in. The route hosts no Control-Tree branch and takes no tree query argument.

The Enable Switch, and Its Two Defaults

AuthConfig binds the Auth section of configuration and is registered as one instance, so the authentication controller and the startup decision read the same object. Two different values answer the question “is the gate on by default”, and conflating them gets the answer wrong on a real instance:

  • The class default is off. Enabled initialises to false, so a service whose configuration carries no Auth section — or an empty one — runs with no login at all.
  • The shipped configuration leaves it off; the Development overlay turns it on. appsettings.json sets Enabled to false and carries no credential entry, so an installed copy — or any instance started outside the Development environment — runs with no login. The Development overlay sets Enabled to true with one credential entry, so dotnet run from the repository, whose launch profiles select Development, demands a sign-in. A deployment that wants the gate sets Enabled and its own Users in its own settings file, as a shared server does.

Four further settings live in the same section. SessionHours defaults to 8 and is raised to 1 at registration if a smaller number is configured. SlidingExpiration defaults to true. CookieName defaults to HiNC.Auth; see The Cookie. Users is a list of username and password pairs and defaults to empty.

That list is the whole credential store. Passwords are held in the configuration file in clear text and compared verbatim — a first-match scan for an exact username and password — so there is no hashing, no lockout, no rate limit and no user database behind it. The class documents the clear-text form as deliberate, because the project also ships as sample code. Every entry grants the same access; there are no roles. Enabled set to true over an empty list therefore admits nobody.

What the Gate Registers

The flag guards exactly three registrations at startup: the name of the data-protection application that seals the cookie (see The Cookie), a cookie authentication scheme, which becomes the application's default and only scheme, and a global authorization fallback policy requiring an authenticated user.

The fallback policy is what does the locking. It applies to every endpoint carrying no authorization metadata of its own — every controller in the host assembly except the authentication one, the /api fallback that answers a route no controller serves, and all eight SignalR hub routes, so with the gate on an unsigned call to a route that does not exist answers 401 like any other API call. UseAuthentication and UseAuthorization are added to the request pipeline unconditionally and are no-ops when neither the scheme nor the policy was registered. See Program and Hosting for the pipeline order this sits in.

Three places in the host carry AllowAnonymous, and they are the only ones that do:

  • The authentication controller itself, marked at class level, so all three of its endpoints stay reachable under the policy that locks everything else.
  • Both SPA fallbacks, so the client shell — which carries no data of its own — can load and show its own login screen.

Two pipeline stages answer before the authorization stage is reached and are unaffected by the policy: static-file serving and the Swagger middleware. The first of those is what lets the login screen render before anyone has signed in — the built bundle and the brand image it draws are physical files under the web root.

With the gate off, none of this is registered: every controller, every hub and every fallback are open, the login endpoint answers success without inspecting anything, the client's navigation guard short-circuits, and the login route bounces the visitor away as it mounts.

The Cross-Site Guard

With the gate on, a page from another site cannot drive the service through a visitor's browser: the browser leaves the Lax cookie off that page's fetches, image loads and WebSockets, so each one meets the fallback policy. Of another site's requests, the Lax cookie still rides on a top-level GET navigation only, which is why no GET route changes state. A page of the same site — another port of the same host name, or another host of the same registrable domain — is first-party to the browser and carries the cookie on every request, so that site should serve nothing but HiNC. With the gate off nothing would stand in the way. The host sends no CORS headers, so another origin cannot read an answer, but that does not stop the request: a form-style POST or an image load is sent without asking, and SignalR's WebSocket transport is not subject to CORS at all. So the host adds one middleware in the gate's place, right after the forwarded headers are applied and only while the gate is off. It decides from two headers a browser sets and a page cannot, in this order:

  1. Sec-Fetch-Site (Fetch Metadata), which current browsers put on every request to a localhost or HTTPS address, image loads and navigations included. same-origin and none — the service's own page, or an address typed or bookmarked — pass at once. same-site and cross-site are answered 403, a page on another port of the same host included, unless the request is a top-level navigation — a GET with Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document — to a path outside /api: a link into the application, which carries nothing out.
  2. Origin, read only when Sec-Fetch-Site is absent. A browser puts it on every CORS request, every request other than GET and HEAD, and every WebSocket handshake. It passes when its host and port equal the request's Host; the scheme is not compared, and X-Forwarded-Host is not read.
  3. Neither header: the request passes — a script, curl, or a GET a browser sends to a plain-HTTP network address, which gets no Fetch Metadata and whose answer the page cannot read.

GETs and hub handshakes take the same steps as every other request. The order is the one OWASP's Fetch Metadata guidance and Go's net/http CrossOriginProtection follow, extended to GETs and hub handshakes; ASP.NET Core 10 offers only token-based antiforgery, so the host carries its own. Each refusal is logged at Information with the path and the header that decided it, and every answer the guard lets through carries frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so that no other site can frame the application and have the framed page's requests pass as the service's own.

A DNS-rebinding page looks same-origin to both checks, so host filtering is the other half. While the gate is off and AllowedHosts is not configured, the host admits only localhost, 127.0.0.1 and [::1]: a request addressed to any other host name is refused with 400 by the framework's host filtering, ahead of the whole pipeline, and the log file keeps the refused name. With the gate on, any name is admitted unless the deployment sets AllowedHosts; a rebinding page then reaches the service under its own name, for which the browser holds no session cookie. The Development overlay is the exception: its one account is public, so it lists the three loopback names itself.

The scheme issues a cookie named by Auth:CookieName, HiNC.Auth unless configured. It is HttpOnly, its SameSite mode is Lax, and its secure policy is same as request — marked Secure when the request itself arrived over HTTPS and issued plainly otherwise, so a plain-HTTP run on a local network still works. That decision is why forwarded headers are applied first in the pipeline: behind a TLS-terminating reverse proxy the scheme must be read from X-Forwarded-Proto rather than from the plain hop between proxy and service.

A browser keeps cookies per host name, not per port, so two instances reached at one host name on different ports share their cookie unless the names differ. Sharing one, signing in to either signs the browser in to the other as well when both read the same data-protection key ring under the same application name, and otherwise replaces the other's cookie with one it cannot read, signing the browser out there. The host names its data-protection application after its content root joined to the cookie name, so two instances share an application name only when they run from the same folder under the same cookie name. Under one cookie name, then, the stations of one installation, started from one folder by one user, share a sign-in, while instances started from different folders sign each other out: they never accept each other's tickets, even over a shared key ring. Either way, only cookie names of their own keep instances' sign-ins apart.

The ticket carries exactly one claim, the name of the matched user. Its lifetime is the configured session length, refreshed on activity when sliding expiration is left on. Sign-in passes no authentication properties, so the ticket is not marked persistent and the cookie carries no Expires attribute: the browser holds it for the browser session, and the ticket's own expiry bounds a session left open.

One event pair is overridden, and it is what makes a single-page client possible at all. A challenge answers 401 and a refusal answers 403, in place of the framework's default redirect to a server-rendered login page. Every protected call therefore fails as data the client can read rather than as a 302 that would arrive at the fetch layer as an HTML page. Both answers set the status code only and carry an empty body, so to a caller that does not know about the gate an unsigned request looks like a service that is not running; the anonymous status endpoint below is what tells the two apart.

The Auth Endpoints

The controller is routed at api/auth and holds three endpoints.

Endpoint Behaviour
GET status Reports whether the gate is enabled, whether this caller is authenticated, the caller's name, and a version string. It is anonymous, so it answers before sign-in and whether or not the gate is on.
POST login With the gate off, returns success without inspecting the body. With it on, scans the configured users for an exact match and either answers 401 with an English message plus the stable code InvalidCredentials, or signs the caller in.
POST logout Signs the caller out when the gate is on, and reports “not authenticated” either way.

A script signs in exactly as the login screen does: POST /api/auth/login with the JSON body { username, password }, then every later request carries the cookie the answer sets — HiNC.Auth unless Auth:CookieName names another — so an HTTP client needs a cookie jar. The pair is compared exactly, username included.

With the gate on, each outcome is written to the service log under the controller's category, never with a password. A sign-in is written as Login: <user> from <address> at Information, a refusal as Login refused for user <name> from <address> at Warning — a run of them from one address is a guessing attempt, and an instance on an open port gets them — and a sign-out as Logout: <user> from <address>. The refused name is the one the caller sent, with control characters replaced by ? so it cannot forge a line of its own, and cut to 64 characters. The address is the connection's remote address as the forwarded-headers stage leaves it; that stage trusts any X-Forwarded-For, so a caller can choose it, and when a forwarded address replaced the connection's own the original is appended as via <address>. With the gate off, sign-in and sign-out write nothing. The Log Viewer Page shows the file these lines land in.

The version the status endpoint reports is the HiNc assembly version, ApiVersion(API). Because the endpoint is anonymous, that value is readable before sign-in, which is what lets the login screen and the menu bar show the same version mark.

Nothing else in the host reads the signed-in identity — the status endpoint and the sign-out log line are its only consumers. Every application service is registered as a process-wide singleton — the host adds no scoped or transient registration of its own — so authentication decides admission rather than identity: two signed-in browsers drive the same project and the same session. See Session State for what that shared state consists of.

The Client Side

The Store

A Pinia store holds the whole client-side picture: whether the gate is enabled, whether this session is authenticated, the user name, the version string, and a ready flag set once the first status probe has resolved. refresh reads the status endpoint; login and logout call their endpoints and update the same fields; a fourth action flips the store to logged-out with no round trip, for the interceptor below. Every transition re-drives the hub gate.

The Navigation Guard

One global beforeEach guard runs the whole client-side decision, in order:

  1. If the store is not ready yet, hydrate it from the status endpoint, inside a try/catch that swallows the failure. The ready flag is set only on success, so a failed probe is retried on the next navigation.
  2. If the gate reports disabled, allow the navigation.
  3. If the target is the login route, allow it.
  4. If the session is not authenticated, redirect to the login route with the blocked target's full path — query arguments, tree included — parked in a redirect argument.
  5. Otherwise allow it.

What the guard does not do is protect anything. It is a navigation redirect: it runs on router navigations only, so it has no bearing on a direct api/ request, on a hub negotiate, or on a static asset, all of which are the fallback policy's business. It reads no per-route metadata, so there is no list of public routes beyond the login route itself and no notion of a role. And it fails open by construction — when the status probe throws, the store keeps the gate disabled and the navigation is allowed, so a back-end hiccup cannot lock a user out of an installation that has no gate.

The 401 Interceptor

A boot file wraps the global fetch once for the whole application. Every API module, and SignalR's own negotiate, call that global, so one wrapper covers a session that expires mid-use: on any 401 response, and only while the store says the gate is enabled, it marks the store logged out and pushes the login route with the current full path in redirect, unless the router is already there. The response is handed back unchanged, so the calling code still sees its own failure and handles it.

Boot order is load-bearing here. The interceptor's boot file is declared ahead of the i18n one, and boot files are awaited in declaration order, so the locale probe made during startup goes through the patched fetch.

The Hub Gate

The shared-hub registry carries one tri-state gate that the store drives on every transition: undecided while the status probe is still resolving, closed while the gate is on and the session is signed out, and open for a signed-in session or a service with no gate. Undecided holds, so a first-paint race never fires a negotiate that is certain to fail; closed keeps every hub idle rather than letting the never-give-up reconnect schedule hammer a /negotiate that answers 401 by design. The store releases the gate even when the status probe fails, so a probe hiccup never strands the hubs of a service that has no login. Opening the gate resumes the connections a caller had already asked for, without the caller asking again.

After a Successful Sign-In

The login screen navigates with a full page load rather than a router navigation. The application's one-shot wiring — the project hub subscription and the first project-status fetch — sits behind a watcher on the auth predicate and runs once per page load, so reloading is what re-runs it with the cookie present.

Signing Out

The logout control sits at the right of the menu bar and renders only while the gate is enabled and the session is authenticated. It is labelled with the signed-in user name, falling back to Logout when the status carries no name, and its tooltip reads Log out. It posts the logout endpoint and then, in a finally, hard-navigates to /login — so a failed request still tears the page down and rebuilds the client from scratch, rather than leaving the singletons of an ended session wired up.

Layout

The route renders outside the shell layout, in a Quasar layout of its own — the same arrangement the catch-all not-found route uses. Nothing of the application frame is present: no menu bar, and therefore no File, Preference or Help menu, no page switches and no language submenu; no cached page panel; no routine-progress footer. The browser tab reads Login - HiNC.

  • Login Page — a single card centred on an empty page
    • Brand Section
      • Brand image, above the literal title HiNC
      • Version caption, v followed by the version string — drawn only when the status probe returned one
      • Prompt caption — Please sign in to continue
    • Separator
    • Sign-In Form
      • Username Text Field — autofocused on arrival, disabled while a sign-in is in flight
      • Password Text Field — masked, with a trailing eye icon that toggles the text visible; the icon's accessible label alternates between Show password and Hide password
      • Error caption — drawn in the negative colour, and only after a failed attempt
      • Sign In Button — submits the form and shows a spinner while the request is in flight

Pressing Enter in either field submits the same form. Neither field carries a validation rule, so an empty pair is submitted and refused by the server like any other wrong pair. The error caption shows the localized “Incorrect username or password” whenever the server pairs its refusal with the InvalidCredentials code, and the server's own English message for any other refusal that carries one.

Two cases send the visitor away again as soon as the screen mounts. The mount hook hydrates the auth status if the first probe has not resolved yet, and then replaces the route with the parked redirect target — or with /, which redirects on to the Execution page — when the gate reports disabled or when the session is already authenticated.

The locale the screen paints in is whatever the boot sequence resolved, because the stored language preference is served by an endpoint the fallback policy locks. With a browser-local cached locale the screen paints in that; without one the awaited probe fails and the locale falls back to the browser's own language, and to English after that. The screen's own strings ship in all five locales.

Source Code Path

See HiNC App Anatomy for git repository links.

Web Application

HiNC-2025-webservice (Quasar CLI SPA):

  • Common/AuthConfig.cs — the bound shape: the enable flag and its class default, the session length, the sliding-expiration flag, the cookie name, and the clear-text user list.
  • Common/AuthController.cs — the anonymous api/auth controller: the status projection, the first-match credential scan with its message and code pair, the single name claim it signs in, the sign-out, and the log line each of them writes with the caller's address and the sanitised name.
  • Program.cs — the flag-guarded registration of the cookie scheme and the fallback policy, the cookie name read from Auth:CookieName and the data-protection application name set from the content root and that cookie name, the cookie flags, the 401 / 403 event overrides, the forwarded-headers configuration ahead of them, the host-filtering default that admits only the loopback names while the gate is off, the cross-site guard's registration right after the forwarded headers, and the two anonymous SPA fallbacks with the /api fallback beside them, which is not anonymous.
  • Common/CrossSiteRequestGuard.cs — the gate-off middleware: the Fetch Metadata test with its navigation exception, the host-and-port Origin test it falls back to, and the framing headers.
  • appsettings.json — the shipped Auth section, off with no account, and no AllowedHosts key; appsettings.Development.json turns the gate on with one account.
  • wwwroot-src/src/pages/LoginPage.vue — the screen: its own Quasar layout, the card and its fields, the bounce on mount, and the full page load after a successful sign-in.
  • wwwroot-src/src/router/routes.ts — the login route outside the shell layout, and the title key the tab shows.
  • wwwroot-src/src/router/index.ts — the navigation guard: the once-only hydration, the disabled and login-route short circuits, and the redirect carrying the full path; also the retitle that composes the tab text from the route's title key.
  • wwwroot-src/src/stores/auth.ts — the client-side state, the four actions, and the hub gate each of them re-drives.
  • wwwroot-src/src/api/auth.ts — the three typed endpoint wrappers, and the mapping of the server's refusal code onto the localized message.
  • wwwroot-src/src/boot/auth.ts — the global fetch wrapper that turns a 401 into a logged-out store and a push to the login route.
  • wwwroot-src/quasar.config.ts — the boot-file order that puts the interceptor ahead of the locale probe.
  • wwwroot-src/src/boot/i18n.ts — the locale resolution the login screen paints in, and its fallback chain when the language endpoint answers 401.
  • wwwroot-src/src/composables/useSharedHub.ts — the tri-state hub gate and what each of its states does to a connection.
  • wwwroot-src/src/App.vue — the one-shot wiring held behind the auth predicate, which is what the post-sign-in page load re-runs.
  • wwwroot-src/src/components/AppMenuBar.vue — the logout control, drawn from the auth store, and the place beside the logo where the version badge sits.
  • wwwroot-src/src/product/VersionBadge.vue — the version badge the web service puts there when it runs on its own, v followed by the auth store's version.
  • wwwroot-src/src/i18n/en/auth.ts — the screen's English strings.
  • wwwroot-src/src/i18n/en/menu.ts — the Logout label the control falls back to, and its Log out tooltip.
  • wwwroot-src/src/i18n/en/routes.ts — the Login route title the browser tab resolves.

HiAPI Engine

  • HiNc/MachiningProcs/MachiningProject.cs — the assembly version the status endpoint reports and the screen shows.

See Also

  • Program and Hosting — the host that binds the Auth section and registers the scheme, the fallback policy and the anonymous carve-outs described here
  • Main Panel — the shell this screen renders outside of, and the menu bar that carries the logout control and the version badge
  • Driving the Web Service over HTTP — signing a script in, and the rest of what a caller of the REST API needs