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/ (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
- 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 - Badge UID Backfill tab — sweep the people whose security key is enrolled but whose badge UID never landed (see Badge UID Backfill)
- 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
| 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.
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:
- 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
125for Process Services). - 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.
- 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, notfido2SecurityKey— 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 -OnlyProblemsExits 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) gets403 accessDeniedon/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 bare125, and an en-dash variant (100 – Maintenance) all occur, and ~109 enabled users have no department at all. Filter withstartswithon the leading numeric code —department eq '125'silently returns nothing.
Also remember the report calls these badges
passKeyDeviceBound, notfido2SecurityKey.
Operational prerequisites (IT/Graph, one-time) — status verified 2026-08-12:
- ✅ The
progressivesurface_badgeUidschema extension is registered and at statusAvailable(a non-owner writer fails while it isInDevelopment). 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 403Authorization_RequestDeniedwhile 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:
| 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 # 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.