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.All → PSI.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
- Search for employee — type name or UPN in the Single User tab; results come from Microsoft Graph and show
DisplayName (#EmployeeId)with UPN below - Select the user — detail panel shows Employee ID and Job Title; existing registered keys are listed
- Enter 6-digit badge number — stamped on the badge; app builds the display name (
C2300 #123456 John Do— truncated to 15 chars) - 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.badgeUidschema extension (see Badge UID Capture below) - Writes an audit entry to
sql2/PSI(dbo.BadgeProvisioningAudit)
- Batch CSV tab — load a CSV (
UPN, DisplayName, BadgeNumber) to provision multiple employees sequentially with pause/skip/resume controls - Tools tab — “Reset Badge PIN” opens Windows Security Key settings so the operator can change a badge PIN
Architecture
| Layer | Technology |
|---|---|
| UI | WPF .NET Framework 4.8, GalaSoft MvvmLight |
| Auth | MSAL v4.72.1 + WAM broker, step-up MFA for FIDO2 write operations |
| Graph API | Microsoft Graph Beta (/authentication/fido2Methods) |
| Hardware | DSInternals.Win32.WebAuthn — wrapper around Windows WebAuthn API |
| Audit log | sql2/PSI dbo.BadgeProvisioningAudit — INSERT-only (Windows auth); also local JSONL fallback |
| Logging | Serilog rolling file → %ProgramData%\Progressive Surface\BadgeProvisioner\logs\ |
Key Service Interfaces
| Interface | Implementation | Purpose |
|---|---|---|
IAuthService | MsalAuthService | Azure AD token acquisition (WAM broker) |
IGraphService | GraphService | All Graph API calls |
IFido2Service | Fido2Service | Windows WebAuthn credential creation |
ICardUidReader | CardUidReader | PC/SC (WinSCard) card-UID read for PRGJSMES badge resolution |
IAuditService | AuditService | SQL audit log writes |
IAuthorizationService | AuthorizationService | Org-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:
- First request to FIDO2 creation options or registration endpoint may return
403 Forbiddenwith aWWW-Authenticate: Bearer claims="..."header SendWithStepUpAsyncinGraphServicedetects this and re-acquires a token with the claims challenge, forcing an ngcmfa step-up- 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_badgeUidschema extension must be registered and promoted to statusAvailable(a non-owner writer fails while it isInDevelopment). - 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:
| Key | Default | Purpose |
|---|---|---|
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\logs | Serilog output path |
AuditConnectionString | server=sql2;integrated security=true;database=PSI | Audit 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.yml → Release: 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 setsGenerateAssemblyInfo=false) sodeploy/bump-version.ps1can rewrite it like every other app. PSI.BadgeProvisioner.slnexists solely so the workflow’snuget restore "$solutionPath"step has a solution to restore; the build itself targets the.csproj.buildOutputSubpathisbin/Release/net48(SDK net48 output subfolder), not the legacybin/Release.- One-time share setup: the
Apps\Badge Provisioner\folder must exist on the deploy share before the first deploy (deploy-to-share.ps1requires 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.exePrerequisites 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.