PSI Badge Provisioner

WPF desktop app for provisioning FIDO2/WebAuthn security keys (HID C2300 badges) as Entra ID authentication methods for PSI employees.

Repo: PSI.AllPSI.BadgeProvisioner/ (on the standard release.yml CI/CD pipeline) Exe: bin\Release\net48\PSI.BadgeProvisioner.exe Version: 1.0.0.9 stable · 1.0.0.11 beta (AMD, CHK only)


What It Does

IT staff use this tool to register HID C2300 USB security key badges in Microsoft Entra ID (formerly Azure AD) on behalf of employees. The badge then becomes a phishing-resistant FIDO2 authenticator — employees can sign in to Microsoft 365 and other Entra-protected apps by inserting the badge and entering their PIN.

Workflow

  1. Search for employee — type name or UPN in the Single User tab; results come from Microsoft Graph and show DisplayName (#EmployeeId) with UPN below
  2. Select the user — detail panel shows Employee ID and Job Title; existing registered keys are listed
  3. Enter 6-digit badge number — stamped on the badge; app builds the display name (C2300 #123456 John Do — truncated to 15 chars)
  4. Click “Provision Badge” — app:
    • Calls Graph API for FIDO2 creation options (challenge nonce)
    • Calls Windows WebAuthn API to make a credential on the physical USB key (prompts the operator to touch the key and enter a PIN)
    • POSTs the resulting credential to Graph API to register it against the user’s account
    • Reads the card’s PC/SC UID and writes it to the user’s Entra progressivesurface_badgeUid.badgeUid schema extension (see Badge UID Capture below)
    • Writes an audit entry to sql2/PSI (dbo.BadgeProvisioningAudit)
  5. Batch CSV tab — load a CSV (UPN, DisplayName, BadgeNumber) to provision multiple employees sequentially with pause/skip/resume controls
  6. Badge UID Backfill tab — sweep the people whose security key is enrolled but whose badge UID never landed (see Badge UID Backfill)
  7. Tools tab — “Reset Badge PIN” opens Windows Security Key settings so the operator can change a badge PIN

Reading the result

A provisioning result is green only when both halves landed — the FIDO2 credential and the badge UID. If the security key registers but the UID does not, the status goes amber (Warning) and says so explicitly: the badge will sign in to Microsoft 365 but will not identify the person in PRGJSMES. The same applies per-row in the Batch CSV grid.

An amber batch row is treated as done on a batch re-run — the security key is registered, and re-running would enroll a second credential on the same person. Finish the missing UID from the Badge UID Backfill tab instead, which runs no FIDO2 ceremony.

The user panel and each search result show badge-UID state unconditionally (UID linked / no UID), so a missing UID is visible before you start rather than after.


Architecture

LayerTechnology
UIWPF .NET Framework 4.8, GalaSoft MvvmLight
AuthMSAL v4.72.1 + WAM broker, step-up MFA for FIDO2 write operations
Graph APIMicrosoft Graph Beta (/authentication/fido2Methods)
HardwareDSInternals.Win32.WebAuthn — wrapper around Windows WebAuthn API
Audit logsql2/PSI dbo.BadgeProvisioningAudit — INSERT-only (Windows auth); also local JSONL fallback
LoggingSerilog rolling file → %ProgramData%\Progressive Surface\BadgeProvisioner\logs\

Key Service Interfaces

InterfaceImplementationPurpose
IAuthServiceMsalAuthServiceAzure AD token acquisition (WAM broker)
IGraphServiceGraphServiceAll Graph API calls
IFido2ServiceFido2ServiceWindows WebAuthn credential creation
ICardUidReaderCardUidReaderPC/SC (WinSCard) card-UID read for PRGJSMES badge resolution
IAuditServiceAuditServiceSQL audit log writes
IAuthorizationServiceAuthorizationServiceOrg-chart authorization check

Authentication Flow

The app uses MSAL with the WAM (Web Account Manager) broker — it picks up the signed-in Windows user’s token automatically (no separate login prompt on normal use).

FIDO2 registration is a sensitive admin operation that Graph may require step-up MFA for. The flow handles this automatically:

  1. First request to FIDO2 creation options or registration endpoint may return 403 Forbidden with a WWW-Authenticate: Bearer claims="..." header
  2. SendWithStepUpAsync in GraphService detects this and re-acquires a token with the claims challenge, forcing an ngcmfa step-up
  3. Retry with the elevated token succeeds

Required Entra Role

The operator’s account must have the Authentication Administrator role in Entra ID. Without it, Graph returns 403 on FIDO2 write operations.

Writing the badge UID is a different operation on a different object — it PATCHes a user property, not an authentication method — and so depends on the operator’s user-write permission rather than their authentication-method permission. Authentication Administrator does carry microsoft.directory/users/basic/update, but User Administrator is the documented least-privileged role for user property writes and is the safer choice for whoever runs a bulk backfill. Current operators: ADevereaux holds Privileged Authentication Administrator + Authentication Administrator; CHosakura additionally holds User Administrator.


Graph API

All FIDO2 calls use the beta endpoint.

GET  /beta/users/{userId}/authentication/fido2Methods
     — list existing keys for a user

GET  /beta/users/{userId}/authentication/fido2Methods/creationOptions(challengeTimeoutInMinutes=5)
     — get creation options (challenge nonce + relying party info)
     — may trigger step-up MFA (403 → retry)

POST /beta/users/{userId}/authentication/fido2Methods
     { "displayName": "C2300 #123456 John Do",
       "publicKeyCredential": { "id": "<base64url>",
         "response": { "clientDataJSON": "...", "attestationObject": "..." } } }
     — register credential; may trigger step-up MFA
     — BODY MUST BE MINIMAL: exactly these four fields. The /beta OData binder rejects ANY extra
       property (a browser "type":"public-key", or @odata.type annotations from the v1.0 docs) with a
       misleading 400 "missing a fido2AuthenticationMethod entity with a displayName and
       publicKeyCredential". This was the #209 regression (see Fido2RegistrationBody.cs).

DELETE /beta/users/{userId}/authentication/fido2Methods/{methodId}
     — remove a key; may trigger step-up MFA

The badge-UID schema-extension write uses the v1.0 endpoint:

PATCH /v1.0/users/{userId}
     { "progressivesurface_badgeUid": { "badgeUid": "042919EA257880" } }   # null clears it
     — write/clear the badge UID; requires User.ReadWrite.All

GET  /v1.0/users?$count=true&$filter=progressivesurface_badgeUid/badgeUid eq '042919EA257880'
     — write-time uniqueness check (ConsistencyLevel: eventual)

Self-Service Restriction

The Microsoft Graph API does not allow an operator to provision their own badge. If the signed-in operator searches for and attempts to provision their own account, the API returns 405 Method Not Allowed. This is an API-level restriction enforced by Microsoft, not a permissions gap.

The app detects this before calling Graph and shows a clear message: “You cannot provision your own badge — ask another IT admin to provision yours.”

This applies to both the Single User and Batch CSV flows.


Badge UID Capture (PRGJSMES integration)

As of June 2026 the same one-tap provisioning flow also captures the card’s physical PC/SC UID and writes it to Entra so PRGJSMES (the thermal-spray MES) can resolve a badge tap to the right user for supervisor approvals. This is the write side of a dual-writer design; PRGJSMES is the read+write side (ProgressiveSurface/PRGJSMES#289). The authoritative format + coordination contract lives in the PRGJSMES repo: docs/integration/psmes-badge-uid-contract.md.

Why a separate read: FIDO2/WebAuthn deliberately abstracts the authenticator and returns a credential ID, not the card’s ISO-14443 UID. So the UID is read by a separate PC/SC transaction (CardUidReader, WinSCard) against the same card — done after the WebAuthn ceremony completes, when the reader handle is free (the ceremony can hold it exclusively).

Canonical format (must match the shop-floor reader byte-for-byte): uppercase hex, no delimiters, no 0x, no byte reversal, variable length — read via the ISO-7816 GET DATA APDU FF CA 00 00 00, require status word 90 00, strip the 2 status bytes. (net48 produces this with BitConverter.ToString(uid).Replace("-", ""), not the .NET-5-only Convert.ToHexString.) Golden vector: Adam’s card → 042919EA257880. Any drift silently breaks every approval tap, since PRGJSMES does exact string equality.

Storage: a shared, tenant-wide Entra schema extension progressivesurface_badgeUid.badgeUid (targetTypes:["User"]), nested shape { "progressivesurface_badgeUid": { "badgeUid": "042919EA257880" } }. Both this app and PRGJSMES write it; PRGJSMES syncs it into AppUser.BadgeCode.

Guarantees:

  • Write-time uniqueness — a UID already on a different user is rejected (one badge → one person).
  • FIDO2 is never corrupted by UID capture — a capture/write failure is surfaced and audited (BadgeUidFailed), but the FIDO2 enrollment still reports success.
  • Clear-on-delete — removing a user’s last security key also clears progressivesurface_badgeUid.badgeUid.

Badge UID Backfill

Badges enrolled before UID capture shipped (June 2026) have a FIDO2 credential but no progressivesurface_badgeUid.badgeUid — as of 2026-08-10 that was 98 of 99 badge holders, including all 71 in dept 125 (Process Services), the population the PRGJSMES tap exists for. Their badge signs in to Microsoft 365 and silently fails to identify them on the shop floor.

The Badge UID Backfill tab sweeps them:

  1. Scan for Missing UIDs — builds the worklist: enabled users who hold an enrolled security key but carry no badge UID. Optionally narrowed by department (enter 125 for Process Services).
  2. Select a person, place their badge on the reader, Capture Badge UID — reads the UID over PC/SC, enforces the same one-badge-one-person uniqueness check as provisioning, writes it to Entra, and advances to the next pending row.
  3. Skip parks a row without marking it done.

No FIDO2 ceremony runs and no security key is re-registered — this only writes the missing attribute.

How the scan works, and why it is not the obvious query: the reports endpoint (/reports/authenticationMethods/userRegistrationDetails) is the natural way to ask “who has a passkey”, but it requires AuditLog.Read.All and a Reports Reader directory role that this app registration does not hold — and because PSI has user consent disabled, adding that scope would block every token until admin consent was granted. Instead the scan reads all enabled users with the schema extension selected (one call), keeps those with no UID, and probes only those for enrolled fido2Methods using UserAuthenticationMethod.ReadWrite.All, which the app already has. Work is therefore proportional to what remains, not to tenant size, and shrinks with every pass. Probes run 4-at-a-time to stay under Graph throttling; a user whose probe fails is logged and omitted rather than aborting the sweep.

Re-running the report yourself: Entra reports these badges as passKeyDeviceBound, not fido2SecurityKey — filtering on the latter returns zero and looks like nobody has a badge at all.

Progress is also visible per user in PSI Portal → User Explorer.

Confirming an enrollment actually landed

Do not trust the app’s own success message as proof — that is precisely what was untrustworthy. PSI.BadgeProvisioner/tools/Verify-BadgeProvisioning.ps1 asks Graph directly and reports FULLY PROVISIONED only when both halves are present:

# right after provisioning someone
.\Verify-BadgeProvisioning.ps1 -Upn jsmith@progressivesurface.com
 
# what is left to sweep on the shop floor
.\Verify-BadgeProvisioning.ps1 -Department 125 -OnlyProblems

Exits non-zero if anything is half-provisioned, so it can gate a sweep as complete. Read-only; needs az login to the PSI tenant.

Two traps it works around, both of which will bite anyone writing their own query:

  • Reading a user’s authentication methods requires Authentication Administrator. A plain directory admin account (e.g. progadmin) gets 403 accessDenied on /authentication/fido2Methods. The script falls back to the authentication-methods registration report, which only needs Reports Reader.
  • Department values are inconsistent. 125 - Process Services, a bare 125, and an en-dash variant (100 – Maintenance) all occur, and ~109 enabled users have no department at all. Filter with startswith on the leading numeric code — department eq '125' silently returns nothing.

Also remember the report calls these badges passKeyDeviceBound, not fido2SecurityKey.

Operational prerequisites (IT/Graph, one-time) — status verified 2026-08-12:

  • ✅ The progressivesurface_badgeUid schema extension is registered and at status Available (a non-owner writer fails while it is InDevelopment). Owner is the PRGJSMES client app registration.
  • ✅ The app registration declares User.ReadWrite.All (delegated) and it is consented tenant-wide (AllPrincipals). Before 2026-08-12 the scope was not declared at all and the only grant was a single per-user grant, so a second operator’s badge-UID write returned 403 Authorization_RequestDenied while FIDO2 registration succeeded.
  • Reader is the HID OMNIKEY 5022 CL; cards are HID Crescendo (ISO-14443A). Confirm UID stability across taps before bulk enrollment (cards can be personalized to emit a random UID).

App Configuration

App.config <appSettings> keys:

KeyDefaultPurpose
TenantId(blank — falls back to hardcoded)Azure AD tenant GUID
ClientId(blank — falls back to hardcoded)App registration client ID
Authority(blank — auto-built from TenantId)AAD authority URL
LogDirectory%ProgramData%\Progressive Surface\BadgeProvisioner\logsSerilog output path
AuditConnectionStringserver=sql2;integrated security=true;database=PSIAudit SQL connection

The SQL connection uses Windows integrated auth — no stored password. The running user must have INSERT permission on dbo.BadgeProvisioningAudit. To record the badge UID in the SQL WORM log, add the column: ALTER TABLE dbo.BadgeProvisioningAudit ADD [BadgeUid] NVARCHAR(64) NULL — the app detects the column and only writes it when present (the local JSONL audit always records it).


Deployment

As of June 2026 the app is on the standard PSI.All CI/CD pipeline (release.ymlRelease: Deploy App) and the deploy/app-registry.json registry, like the other Pro Apps. Visibility is intentionally limited to AMD (Adam) and CHK (Chris) via the registry access list and beta-tester list — it is not a general-rollout app. Pro Update delivers it to those users only.

Pipeline notes specific to this app (it is the first SDK-style app on the pipeline):

  • Version is owned by Properties/AssemblyInfo.cs (csproj sets GenerateAssemblyInfo=false) so deploy/bump-version.ps1 can rewrite it like every other app.
  • PSI.BadgeProvisioner.sln exists solely so the workflow’s nuget restore "$solutionPath" step has a solution to restore; the build itself targets the .csproj.
  • buildOutputSubpath is bin/Release/net48 (SDK net48 output subfolder), not the legacy bin/Release.
  • One-time share setup: the Apps\Badge Provisioner\ folder must exist on the deploy share before the first deploy (deploy-to-share.ps1 requires the app folder to pre-exist).

Dispatch (beta, limited to Adam + Chris):

$env:GH_HOST = 'progressivesurface.ghe.com'
gh workflow run release.yml `
  -f app_folder=PSI.BadgeProvisioner `
  -f release_type=beta `
  -f release_notes="..." `
  -f beta_users="AMD,CHK"

Manual build (local dev):

cd C:\git\PSI.All\PSI.BadgeProvisioner
dotnet build -c Release
# EXE: bin\Release\net48\PSI.BadgeProvisioner.exe

Prerequisites on the provisioning workstation:

  • Windows 10/11 with WebAuthn API (always present on modern Windows)
  • Azure AD joined or Hybrid-joined (WAM broker requires this)
  • Operator’s AD account must have Authentication Administrator role in Entra ID
  • USB port available for the HID C2300 key

Logs

%ProgramData%\Progressive Surface\BadgeProvisioner\logs\provisioner-YYYYMMDD.log

Rolling daily, 30-day retention. Serilog writes Debug and above. Each provisioning attempt logs the full Graph API call chain including step-up auth decisions.


Source Location

C:\git\PSI.All\PSI.BadgeProvisioner\

PSI.BadgeProvisioner/
├── PSI.BadgeProvisioner.csproj   # SDK-style, net48, UseWPF=true
├── App.config                     # Tenant ID, client ID, SQL connection
├── App.xaml / App.xaml.cs         # Startup, Serilog init
├── MainWindow.xaml / .cs          # 4-tab layout (Single User, Batch CSV, Badge UID Backfill, Tools)
├── badge.ico
├── Models/
│   ├── EntraUser.cs                 # + BadgeUid / Department / HasBadgeUid
│   ├── BackfillCandidate.cs         # one row of the backfill worklist (observable)
│   ├── Fido2Method.cs
│   ├── ProvisionResult.cs           # + BadgeUid
│   └── AuditEntry.cs                # + BadgeUid
├── Services/
│   ├── IAuthService.cs / MsalAuthService.cs
│   ├── IGraphService.cs / GraphService.cs   # + WriteBadgeUidAsync / FindUsersByBadgeUidAsync / ClearBadgeUidAsync / FindBadgeUidBackfillCandidatesAsync
│   ├── BadgeDirectoryQuery.cs       # Graph $select/$filter shapes + schema-extension parse (unit-tested)
│   ├── IFido2Service.cs / Fido2Service.cs
│   ├── ICardUidReader.cs / CardUidReader.cs # PC/SC (WinSCard) UID read — FF CA 00 00 00
│   ├── BadgeUid.cs                  # canonical UID format + GET DATA parse (unit-tested)
│   ├── IAuditService.cs / AuditService.cs
│   └── IAuthorizationService.cs / AuthorizationService.cs
├── ViewModels/
│   ├── MainViewModel.cs
│   └── ViewModelLocator.cs
├── Converters/
│   └── StatusToColorConverter.cs
└── TestApi/                       # .NET 8 console test harness (excluded from WPF build)
    ├── TestApi.csproj
    └── Program.cs                 # Direct Graph API endpoint probe (device code flow)

History: Source was recovered from the compiled exe (via ICSharpCode.Decompiler) in April 2026 after the original session never committed to git. All 21 types successfully recovered.