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/ Branch: feature/badge-provisioner-source-recovery Exe: bin\Release\net48\PSI.BadgeProvisioner.exe Version: 1.0.0.4


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. Tools tab — “Reset Badge PIN” opens Windows Security Key settings so the operator can change a badge PIN

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.


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>", "type": "public-key",
         "response": { "clientDataJSON": "...", "attestationObject": "..." } } }
     — register credential; may trigger step-up MFA

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.

Operational prerequisites (IT/Graph, one-time):

  • The progressivesurface_badgeUid schema extension must be registered and promoted to status Available (a non-owner writer fails while it is InDevelopment).
  • The app registration needs User.ReadWrite.All (application) consent to write the user attribute.
  • 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          # 3-tab layout (Single User, Batch CSV, Tools)
├── badge.ico
├── Models/
│   ├── EntraUser.cs                 # + BadgeUid
│   ├── Fido2Method.cs
│   ├── ProvisionResult.cs           # + BadgeUid
│   └── AuditEntry.cs                # + BadgeUid
├── Services/
│   ├── IAuthService.cs / MsalAuthService.cs
│   ├── IGraphService.cs / GraphService.cs   # + WriteBadgeUidAsync / FindUserByBadgeUidAsync / ClearBadgeUidAsync
│   ├── 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.