Table of Contents

API : Passkey (WebAuthn) authentication

Introduction

A passkey is a FIDO2/WebAuthn credential - Face ID, Touch ID, Windows Hello, a phone or a USB security key - that replaces the employee's password entirely. Login is usernameless: the authentication options endpoint accepts no username and returns no allowCredentials, so the browser's own discoverable-credential picker decides which account logs in, and the endpoint reveals nothing about which accounts exist.

A passkey requires user verification (biometric or PIN) at both registration and authentication. Because that is itself two factors, a successful passkey login skips SMS and authenticator (TOTP) validation - security.token.clearSmsKey() still runs, exactly as it does after a password login.

This page is the overview for the whole feature. The endpoints themselves are documented on:

Related collections and keys: portals, employeewebauthncredentials, employeewebauthnenrolltokens, webauthn.

Relying party model

A WebAuthn credential is bound to one relying party id (rpId), which must be the exact hostname or a parent domain of the origin it was created on. The allowlist of origins that may authenticate, and which rpId each of them maps to, is the Portals collection - keyed by hostname, not hard-coded, so adding a reseller portal is a database row.

baked into every stored credential, so changing the anchor later would invalidate all of them.

rpId itself nor a subdomain of it) can only authenticate against the anchor by being

  published as a **Related Origin Request**. Browsers fetch
  ''https://telecomx.dk/.well-known/webauthn'' (served by ''GET /.well-known/webauthn'', proxied
  there by web1/web2) and accept any origin listed in its ''origins'' array.
* The well-known file is generated from the collection, not hand-maintained: every ''ACTIVE''
  portal with ''relatedOrigin: true'', as ''https://<hostname>''.
* **Only 5 unique registrable-domain (eTLD+1) labels are guaranteed to be honoured by a
  browser** - surplus entries are silently ignored. Writing a portal with ''relatedOrigin: true''
  is refused with ''relatedOrigin'' / 422 if it would push the count of unique foreign labels
  above 5. The anchor's own label (''telecomx'') is free and never counts.
* A portal's ''rpId'' is refused outright if it is a single label such as ''dk'' or ''com'' - a
  public suffix, which no browser will ever accept as a relying party. ''localhost'' is the one
  legitimate single-label exception, and only in development. See [[api:tools-admin:portals]] for
  the exact write-time checks.

webauthnOnly

Employee.webauthnOnly makes passkey login mandatory for that account. Turning it on blocks every other way of authenticating with a password:

Where Result
POST /auth/login 401 webauthn_only - see login
GET/POST /auth/reset 403 webauthn_only
GET/POST /auth/setpassword 403 webauthn_only
CardDAV Basic Auth 401, unless the “password” presented is one of the employee's own persistent tokens - see carddav-token

Two things that are not blocked, deliberately:

CardDAV, minted separately as an app-specific password), so webauthnOnly never revokes a

  token that is already out there.
* **Impersonation (''GET /auth/impersonate/:employee'') is not blocked.** No password is
  presented in that flow, so there is nothing for the flag to intercept.

Writing the flag is guarded so it can never lock an account out: it is refused with webauthnOnly / 422 on an employee with zero registered credentials, and it is mutually exclusive with m2mOnly (setting one while the other is already true is also webauthnOnly / 422).

Flipping webauthnOnly on does not itself invalidate whatever tokens the employee already holds - a session token issued five minutes earlier under the password path keeps working until it expires. An admin who wants the switch to take effect immediately should follow it with GET /employee/:id/cleartokens.

Recovery: an employee who has lost every passkey

An employee with no working passkey (new device, factory reset, uninstalled password manager) is recovered with a single-use enrollment token, never by falling back to a password:

  1. An admin calls POST /employee/:id/webauthn/enroll with { hostname, method } - see

webauthn. hostname must name an ACTIVE row in

  [[priv:mongo:portals|Portals]]; its ''enrollUrl'' template decides where the link points, and
  the ''rpId'' the eventual passkey is created for is whichever portal the employee actually opens
  the link (or pastes the code) on - not necessarily the one the admin picked.
- A 32-byte, base64url token is stored in
  [[priv:mongo:employeewebauthnenrolltokens|EmployeeWebauthnEnrollTokens]], keyed on the
  employee's own ''_id'' - so there is at most one pending enrollment per employee, and issuing a
  new one silently replaces an older unused one. It is valid for **one hour** and is consumed
  exactly once, atomically, by ''POST /auth/webauthn/enroll''.
- The employee receives the token **two ways in the same e-mail or SMS**: as a clickable link
  (''enrollUrl'' with ''{token}''/''{lang}'' filled in) and as a plain, copy-pasteable code (the
  raw token).
- **The code is the only route that works on the macOS Communicator app.** Its Touch ID
  passkeys live in an app-private Secure Enclave keychain-access-group, wired up by Electron's
  ''app.configureWebAuthn()'' - a credential created by clicking the link in Safari or Chrome is
  created in the *browser's* store and is invisible to the Electron app, and vice versa. The
  Communicator login dialog has a "Jeg har en passkey-kode" field that pastes the code in and
  drives the same ''POST /auth/webauthn/enroll/options'' + ''POST /auth/webauthn/enroll'' calls
  from inside the app, so the resulting credential lands in the app's own store. On Windows this
  distinction does not exist - Chromium there delegates to the system's Windows Hello /
  WebAuthn API, so the link works normally inside the Electron window too.
- See [[api:auth:webauthn-enroll]] for the two enrollment endpoints themselves.

Errors

/auth/webauthn/*

Error code Message Description
401 origin_invalid Origin header missing, not https (outside development), or not a known/active portal. Thrown by every endpoint that resolves a relying party: register/options, register, authenticate/options, authenticate, enroll/options, enroll
401 challenge_invalid The challenge id is malformed, expired, already spent, or its stored rpId/origin/employee does not match this request
401 verification_failed The authenticator's signature or client data could not be verified
401 credential_unknown The credential id in the response is not registered (authenticate only)
401 user_verification_required The authenticator did not perform user verification (authenticate only)
401 account_disabled Employee state is not ENABLED, access level is NO_LOGIN, or the employee's customer is not ACTIVE (authenticate, enroll/options, enroll)
401 m2m_only The employee is m2mOnly and must authenticate as M2M instead (authenticate, enroll/options, enroll)
401 ipaddress_invalid The employee's ipRestriction excludes the caller's IP (authenticate only)
401 enroll_token_invalid The enrollment token is unknown, already consumed, or older than one hour (enroll/options, enroll)
404 customer The employee's customer document could not be found (authenticate, enroll/options, enroll)
404 credential The passkey does not exist or belongs to someone else (rename, delete)
409 credential This credential id is already registered - to anyone (register, enroll)
422 last_credential Refused: this is the last passkey on a webauthnOnly account (delete)
429 (empty) Rate limit exceeded on the login scope (authenticate/options, authenticate, enroll/options, enroll)

Full detail on each of these is on the endpoint's own page.

Elsewhere in the feature

Error code Message Description
401 / 403 webauthn_only Password path attempted on a webauthnOnly account - see “webauthnOnly” above. Never returned by an /auth/webauthn/* endpoint itself
422 webauthnOnly webauthnOnly set with zero credentials, or together with m2mOnly - POST /employee, POST /employee/:id
404 not_found Employee does not exist - GET/DELETE /employee/:id/webauthn*, POST /employee/:id/webauthn/enroll (pre-existing Employee.load behaviour, not specific to passkeys)
422 hostname hostname does not name a known portal - POST /employee/:id/webauthn/enroll
409 hostname Portal hostname already exists - portals
422 rpId rpId is a public suffix, or cannot serve the hostname without relatedOrigin - portals
422 relatedOrigin Publishing this portal as a related origin would exceed the 5-label budget - portals
404 portal Portal does not exist - portals

Metrics

Every outcome of registration, authentication and enrollment is counted in one Prometheus counter, tcx_api_webauthn_events_total{kind, result, code} (kindregistration, authentication, enrollment; resultok, failed). code is bounded to a fixed set of labels so no caller-controlled value can ever become a label.

It emits ok (no code), and on failure: challenge_invalid, verification_failed, credential (the 409 duplicate-registration case only), account_disabled, m2m_only, customer, enroll_token_invalid, credential_unknown and user_verification_required.

It does not emit: origin_invalid (thrown by relying-party resolution, before any counter call), the 404 credential case from rename/delete, last_credential, webauthn_only, the 429 rate-limit rejection, or any of the /employee/:id/webauthn/enroll and /tools/portals specific codes (hostname, rpId, relatedOrigin, portal, webauthnOnly, not_found). Those flows do not touch this counter at all.