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.
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.
| Mode | Who can sign in | When 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.
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:
- Add it directly --
POST /api/tenants(the "Add a tenant" form on the Tenants screen) if you already have the GDAP relationship and just want to start automating against it. - Sync from Partner Center -- running a sync grants you Owner on any newly discovered tenant. A tenant the system already knew about just gets its name and domain refreshed; ownership is untouched.
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:
| Role | Can do |
|---|---|
| Viewer | Read dashboard, history, and diagnosis output for that tenant. Cannot remediate or deploy. |
| Operator | Everything a Viewer can, plus run known-fix workflows and Win32 deployments. |
| Owner | Everything 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.
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
| Key | Applies to | Notes |
|---|---|---|
Auth:Mode | All | Oidc (default) / Local / Dev. Auth:Enabled=true|false is still read as a fallback for config predating this key. |
Auth:Authority, Auth:Audience, Auth:NameClaim | Oidc | Standard OIDC discovery + JWT bearer validation settings. |
Auth:Local:SigningKey | Local | Secret. 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, AccessTokenLifetimeHours | Local | Defaults: 12 chars, 10 attempts, 15-minute lockout, 12-hour tokens. |
Auth:Local:Passkey:RelyingPartyId | Local | Bare domain the SPA is served from (no scheme/port), e.g. pcbridge.example.com. |
Auth:Local:Passkey:RelyingPartyName | Local | Display name shown by the OS passkey prompt. |
Auth:Local:Passkey:Origins | Local | Exact origin(s) (scheme+host+port) the browser sends, e.g. https://pcbridge.example.com. A mismatch fails every ceremony. |