Authentication

The operator plane -- who is allowed to use the bridge, and against which tenants -- is independent of the Microsoft plane (the SAM app that gives the bridge access to customer tenants; see Architecture). This page covers the operator plane's three modes, and the self-service account model that ships with the local one.

Login screen with a primary Sign in with a passkey button above a password fallback form and a Register link.

Three modes

Set with Auth:Mode. Nothing else in the app changes based on the mode -- controllers use the same [Authorize] attribute and the same User.Identity.Name regardless of which one issued the token.

ModeWho can sign inWhen to use it
Oidc (default) Anyone your OIDC provider (Authentik, or any standard OIDC IdP) authenticates. Every signed-in user has full, unrestricted access to every tenant -- this mode predates the tenant-sharing model below and keeps that original all-access behavior. You already run an IdP and want every technician to go through it.
Local Anyone who registers an account directly in the app. No external IdP required. See below for the full account/sharing/2FA/passkey model. Solo technicians, small teams, or anyone self-hosting without wanting to stand up Authentik first. This is the friction-free default for adoption.
Dev (Auth:Enabled=false) Everyone, as a fixed dev-operator identity. No credentials at all. docker-compose evaluation only. Never on a deployed instance -- see the warning on Getting started.

Local accounts: open signup, scoped power

Registration (POST /api/auth/register) is deliberately open: no invite code, no admin approval queue. The safety story isn't the signup gate, it's that a fresh account starts with zero tenant access. It can log in and see the app shell, but can't run a single workflow or deployment against a customer tenant until someone shares that tenant with it.

There is no admin flag that bypasses this. IsSystemAdmin (granted automatically to the very first account on a fresh database) gates exactly one thing: the SAM refresh-token admin endpoints (/api/admin/sam/*) -- instance-level infrastructure, not tenant access. Every tenant permission, for every account including the system admin's, comes from an explicit, visible grant.

Owning a tenant

You become a tenant's Owner automatically, the moment you bring it into the system, two ways:

Sharing (the collab model)

An Owner can grant another registered user access to that tenant, self-service, from the Tenants screen -- no central admin has to be the bottleneck for every hand-off:

RoleCan do
ViewerRead dashboard, history, and diagnosis output for that tenant. Cannot remediate or deploy.
OperatorEverything a Viewer can, plus run known-fix workflows and Win32 deployments.
OwnerEverything an Operator can, plus grant or revoke other users' access to that tenant, and reassign its contract.

Grants can carry an optional expiry -- useful for a contractor or a temporary engagement.

Two-factor authentication (TOTP)

Any local account can enable RFC 6238 TOTP from the Security screen. Enrollment is a two-step confirm: the app shows a secret (and an otpauth:// URI you can paste into most authenticator apps -- there's no QR code rendered client-side, deliberately: a live TOTP secret isn't something to hand to a third-party QR image service), and enabling only completes once you prove you captured it by entering a live code. Ten single-use recovery codes are shown exactly once at that point.

Once enabled, a password login returns an MFA challenge instead of a token; the account isn't considered logged in until the second factor clears. A wrong code doesn't burn the challenge -- it's retryable (bounded) within its short server-side lifetime, so a typo doesn't force you back through your password. A successful passkey login skips this step entirely: WebAuthn possession-plus-verification is already MFA-equivalent.

Passkeys (primary sign-in)

Passkeys are the primary way to sign in, not a bolt-on: registered as discoverable (resident) credentials, so the login screen's "Sign in with a passkey" button needs no email typed first -- the browser/authenticator presents whichever passkeys it holds for the site and the user picks one. Password stays the permanent fallback credential; it's never removable from an account, so a lost device or a browser without WebAuthn support can't lock anyone out.

Passkey ceremonies (/api/auth/passkey/*) are standard WebAuthn/FIDO2 via Fido2NetLib -- no proprietary protocol, works with platform authenticators (Windows Hello, Touch ID) and roaming ones (security keys) alike.

Security screen listing a registered passkey and showing TOTP enabled, with a disable-2FA password confirmation form.

Audit trail

Every security-relevant event is recorded to an append-only AuditEvents table: registration, login success/failure, logout, TOTP enable/disable, a recovery code being spent, passkeys added/removed, and every tenant-access grant or revoke -- who, when, and (for access changes) what role. Mutations to sensitive entities (accounts, tenants, contracts, templates, deployments, tenant grants) are captured automatically by an EF Core save-changes interceptor, so logging isn't something a future controller can forget to do. This is in addition to, not instead of, the workflow-run history described in Architecture.

Configuration reference

KeyApplies toNotes
Auth:ModeAllOidc (default) / Local / Dev. Auth:Enabled=true|false is still read as a fallback for config predating this key.
Auth:Authority, Auth:Audience, Auth:NameClaimOidcStandard OIDC discovery + JWT bearer validation settings.
Auth:Local:SigningKeyLocalSecret. Base64, 32+ bytes (openssl rand -base64 32). Signs locally issued JWTs; required to start in Local mode. Rotating it invalidates every outstanding token.
Auth:Local:MinPasswordLength, MaxFailedLogins, LockoutMinutes, AccessTokenLifetimeHoursLocalDefaults: 12 chars, 10 attempts, 15-minute lockout, 12-hour tokens.
Auth:Local:Passkey:RelyingPartyIdLocalBare domain the SPA is served from (no scheme/port), e.g. pcbridge.example.com.
Auth:Local:Passkey:RelyingPartyNameLocalDisplay name shown by the OS passkey prompt.
Auth:Local:Passkey:OriginsLocalExact origin(s) (scheme+host+port) the browser sends, e.g. https://pcbridge.example.com. A mismatch fails every ceremony.