NFC TAP Bridge
Passwordless NFC badge sign-in for shared shop floor devices — FIDO2 authentication + Temporary Access Pass + Microsoft Managed Home Screen.
Overview
The NFC TAP Bridge system enables passwordless sign-in for shared Android devices (Zebra TC52 scanners) on the manufacturing floor. Workers tap their NFC security badge, enter a PIN, and the system authenticates them via FIDO2 → creates a Temporary Access Pass → establishes a Primary Refresh Token (PRT) through the Microsoft Authenticator broker. This gives SSO across Teams, Edge, PS MES, and other Microsoft apps.
Repository: psi-nfc-launcher
Current Version: v2.14.28 (versionCode 1030) — production track, Managed Google Play
Package Name: com.psi.nfclauncher
versionCode rule — read before publishing. CI publishes at
run_number + 1000(≥ 1000), andbuild-android.ymlhard-fails an explicitversion_codebelow 1000. Codes under 1000 are reserved for local sideload/test builds. This matters because local test builds have historically been hand-numbered in the 9xx range: v2.14.27 shipped to Play at code 258, and every handheld carrying a 9xx test sideload was therefore unreachable by it for weeks — Android never downgrades a versionCode, and a published code cannot be re-uploaded. v2.14.28 (1030) exists solely to clear that floor; its app code is byte-identical to v2.14.27.
Components
| Component | Location | Purpose |
|---|---|---|
| PSI Logon (NFC Launcher) | app/ | Android app — orchestrates auth, NFC, and broker sign-in |
| TAP Bridge | functions/TapBridge/ | Azure Function — creates TAPs via Graph API |
| TapInjectorService | app/.../service/ | Accessibility service — injects TAP into broker password field |
| Microsoft Managed Home Screen | Intune config | Enterprise kiosk launcher — app grid for operators |
| Microsoft Authenticator | Device | Broker app in shared device mode — issues PRT |
Architecture
Microsoft Managed Home Screen (kiosk launcher, sign-in disabled)
|
| User taps "PSI Logon" icon
v
PSI Logon App → Login screen → "Sign In" button
|
| 1. User enters FIDO2 PIN
| 2. User taps NFC security badge
v
FIDO2 WebAuthn Auth (WebView → Entra ID → CTAP2 over NFC)
|
| Auth code returned via msauth:// redirect
v
Token Exchange (direct HTTP to Entra token endpoint)
|
| POST /api/create-tap?code=<function-key>
| Body: { "upn": "user@progressivesurface.com" }
v
Azure Function (psi-tap-bridge)
|
| 1. Verify user is in allowed Entra group
| 2. Create TAP via Microsoft Graph API
v
Returns: { "tap": "A9eM#FKy", "lifetime": 60 }
|
| TapInjectorService injects TAP into Authenticator broker
| (handles passkey fallback page automatically)
v
Microsoft Authenticator → PRT established → SSO for all apps
|
| Green check + user name (2 seconds)
v
Auto-finish back to MHS home screen
PSI Logon (Android App)
The PSI Logon app (formerly “PSI Launcher”) handles NFC badge authentication and session management. It works alongside Microsoft Managed Home Screen as a dedicated auth utility — not a launcher.
Operator Experience
| Step | What Happens |
|---|---|
| Sign in | Open PSI Logon from MHS → enter PIN → tap badge → green check with name (2s) → auto-return to MHS |
| Use apps | From MHS, tap Teams, PS MES, or Edge — SSO via PRT, no additional login |
| Sign out | Open PSI Logon → tap Sign Out → return to MHS |
| Wrong PIN | Red “Incorrect PIN” banner shown, PIN retry dialog opens automatically |
| Badge lost contact | “Badge lost contact. Hold your badge steady and try again.” |
Reader location (front vs back). On newer TC53E units the NFC antenna is on the front — the badge is laid flat on the screen (upper area), not the back. The sign-in UI adapts to the model: front-reader devices show a “Place badge here” target up top and move all guidance to the bottom (so it stays visible under the badge), with a clear place → hold → “you can remove your badge” progression. Back-reader devices keep the centered “hold against the back” layout. Detection is by Build.MODEL; the nfc_reader_location managed-config key (front/back/auto) overrides it per device group if needed. (v2.14.27+)
Key Technical Details
- FIDO2 via WebView: Desktop user-agent WebView loads Entra authorize URL, JavaScript bridge intercepts
navigator.credentials.get()calls for CTAP2 over NFC - Passkey fallback: If Android Credential Manager triggers a failed passkey flow during broker sign-in, the app automatically clicks “Use my password” and re-injects the TAP
- MSAL shared device mode:
getCurrentAccountAsyncfires bothonAccountLoadedandonAccountChangedon shared devices — guarded with aresumedflag to prevent double-resume crash - Session management: 12-hour idle timeout (safety net), MHS handles session lifecycle
- CI/CD: GitHub Actions publishes signed APK to Managed Google Play via
r0adkll/upload-google-playaction
TAP injection mechanism (validated 2026-07-16)
The TAP has to be typed into the Microsoft Authenticator broker’s sign-in field — an app PSI does not own, so it can’t be JS-injected like the FIDO WebView. This is done with an AccessibilityService (TapInjectorService): when the broker focuses its TAP field, the service sets the text via ACTION_SET_TEXT and submits via ACTION_IME_ENTER (the v2.14.25 fix), and it dismisses the Credential Manager passkey selector so the broker falls through to the TAP page. Validated end-to-end on a clean, freshly Intune-enrolled A14/MX15.2 TC53E (app v2.14.26): badge → TAP injected → broker PRT → SSO to Teams/Outlook.
Enabling the accessibility service on the managed fleet is the crux, because every declarative path is blocked: Zebra AccessMgr’s accessibility grant (ACCESSIBILITY_SERVICE_ACCESS, code 13) is Device-Owner-only on MX 15.0+, and PSI can’t be Device Owner (CloudDPC/Intune holds it); WRITE_SECURE_SETTINGS isn’t grantable via AccessMgr or the DPC. The working path is a one-time ADB staging step: WRITE_SECURE_SETTINGS is development-flagged, so adb shell pm grant grants it — after which the app self-enables its own accessibility service on launch, and the grant survives reboots and same-signature app updates. Staging enables USB ADB via OEMConfig — in Intune, edit the OEMConfig profile → System Configuration → Pass-Through Command and paste the UsbMgr / UsbADBUsage=1 MX XML — then runs scripts/enable-accessibility.ps1. Full procedure: doc/accessibility-staging-runbook.md in the repo.
Approaches ruled out (do not re-attempt):
- Custom IME (
PsiTapKeyboardService) — a keyboard only commits into a focused field, and the broker does not auto-focus its TAP field (an app can’t focus another app’s field), so it can’t inject hands-free. - Credential Manager provider — registering PSI as a passkey/WebAuthn provider doesn’t work: the broker routes passkey requests to Google Play Services directly (
androidx.credentials.playservices.HiddenActivity→ GMS), bypassing the Android system Credential Manager and all third-party providers.
There is no injection_method choice — accessibility is the only shipping path. (Earlier revisions of this page described an injection_method managed-config key defaulting to ime; that was written during the IME spike, which was rolled back off master as non-viable. Ignore any reference to it.)
An unstaged device fails as a false success — and now says so (2026-08-19). This is the failure mode to understand before anything else on this page. With no accessibility service the broker never receives a TAP, the flow times out, and the app falls through to success_fallback — a local FIDO session with no PRT. The operator gets a green check and their own name, then finds Teams and PS MES don’t recognise them. Nothing anywhere says “this device was never staged”, so it reads as a broken app and can sit unnoticed for months. As of the change on feat/surface-unstaged-device, the login screen shows an amber “Device setup not finished” panel above the Sign In button whenever the accessibility service isn’t enabled, ProvisioningStatus logs at ERROR (adb logcat -s ProvisioningStatus:E is a one-command fleet check), and every sign-in attempt records provisioning_state | a11y=… wss=… in the trace log so a support pull tells staging faults apart from broker faults without the device in hand. Sign-in is deliberately not blocked — the fallback session still identifies the user.
Audit the fleet on the grant, not on the app version (2026-08-19). A Play-delivered install is not a provisioned install. TC53E 25290524209495 was found carrying the current v2.14.28 from Managed Google Play and was still completely unstaged — WRITE_SECURE_SETTINGS: granted=false, enabled_accessibility_services=null. Play upgrades preserve an existing grant (below); they never create one. So a handheld on the newest build can still be unable to badge in, and the fleet check that matters is dumpsys package com.psi.nfclauncher | grep WRITE_SECURE_SETTINGS, not the version number.
The ADB staging path is MX-version-independent (2026-08-19). The same staging succeeded first try on an Android 13 / MX 13.x TC53E (13-24-08.00-TG-U00-STD-NEM-04) — the exact population where OEMConfig and EMDK accessibility grants have never worked. pm grant and settings put secure go through PackageManager and SettingsProvider and never touch MX, so no OS upgrade is a prerequisite for badge sign-in; the one-time USB step covers the whole fleet, A13 and A14 alike. Verify with dumpsys accessibility — a correctly staged device shows Bound services:{Service[label=PSI Logon, ..., capabilities=33, ...]}, where capabilities=33 is CAN_RETRIEVE_WINDOW_CONTENT + CAN_PERFORM_GESTURES, the two the injector needs.
Play updates do not undo the staging (validated 2026-08-10). On TC53E 25290524209420, a Managed Google Play in-place upgrade from a sideloaded build to v2.14.28 left WRITE_SECURE_SETTINGS granted and TapInjectorService still enabled; firstInstallTime was unchanged, so it was a true upgrade rather than remove+reinstall. Shipping a new app version therefore does not require re-running scripts/enable-accessibility.ps1 across the fleet — only a factory reset or an actual uninstall does. The same event showed Play taking ownership of a sideloaded install (installer flipped null → com.android.vending), so devices staged by ADB rejoin the managed update track on their own once Play has a higher versionCode to offer.
Auto-update timing. Managed Play’s Default auto-update mode installs only when the device is charging, idle, on an unmetered network, and not in active use — expect hours, not minutes. Set the Intune assignment’s App auto update mode to High Priority if the fleet needs to converge promptly after a publish.
Note: could not load root hash from incremental install and Failed to measure fs-verity appear on every Play install on this Zebra build (Teams and Photos included) — normal noise, not a failure signal.
First badge sign-in after idle fails, retry works (2026-08-10)
Two separate causes, established from latest-auth-trace.log on 25290524209420. Don’t conflate them:
- The failure itself was the NFC read. The failed attempt ended
ctap_assertion_failed | Tag was lost.and never reachedtap_bridge_start— the TAP bridge was never contacted. CTAP-over-NFC needs the badge held still for the whole assertion, so a lift or a misplaced badge ends the attempt. On TC53E the antenna is on the front, so the badge goes flat on the screen; see the reader-location note above. A separate retained attempt endedoutcome=error | cancelled— the badge-prompt timeout, a third distinct mode. - The bridge really was sleeping, but that wasn’t this error.
psi-tap-bridgehadalwaysOn: falseon its Basic plan, so it unloaded after ~20 min idle. Measured TAP creation: 2.37 s / 2.63 s warm vs 6.90 s cold — ~4.5 s of first-call penalty, enough to push a sign-in toward its timeout.alwaysOnis nowtrue; expect first-of-the-day TAP creation at the ~2.5 s warm figure.
Diagnostic recipe: pull /sdcard/Android/data/com.psi.nfclauncher/files/latest-auth-trace.log and read the attempt_end | outcome= lines. tap_bridge_start present or absent tells you immediately whether the bridge was even in the picture.
Managed Home Screen (MHS)
Microsoft Managed Home Screen (com.microsoft.launcher.enterprise) runs as the kiosk launcher on TC52 devices. Sign-in is disabled — MHS is a “dumb shell” that shows the app grid.
Configuration
Configured via Intune App Configuration Policy using Managed Google Play JSON format:
| Setting | Value | Purpose |
|---|---|---|
enable_mhs_signin | false | No MHS sign-in — PSI Logon handles auth |
exit_lock_task_mode_code | 1234 | IT admin escape hatch |
screen_orientation | 1 | Portrait lock |
grid_size | 4;2 | 4 rows x 2 columns |
icon_size | 2 | Large icons for gloved hands |
lock_home_screen | true | Operators can’t rearrange |
theme | 2 | Dark theme for shop floor |
top_bar_user_name_style | Show both | Name + email in top bar |
enable_show_organization_logo | true | Square “Pro” logo |
App Allowlist
| App | Package |
|---|---|
| PSI Logon | com.psi.nfclauncher |
| PS MES | com.progressivesurface.mes |
| Microsoft Teams | com.microsoft.teams |
| Microsoft Edge | com.microsoft.emmx |
Azure Resources
| Resource | Value |
|---|---|
| Function App | psi-tap-bridge |
| Resource Group | PS-WEBAPPS |
| App Service Plan | asp-erp-migration-tool (B3 Linux, shared) |
| Runtime | .NET 8 Isolated (DOTNET-ISOLATED|8.0) |
| Private Endpoint | psi-tap-bridge-pe (10.160.140.6, PS-ProdData) |
| Public Access | Disabled |
| Storage | psargostorage (shared, for Azure Functions host) |
| Managed Identity | System-assigned (Key Vault access) |
Entra ID Configuration
| Resource | Value |
|---|---|
| App Registration | PSI TAP Bridge |
| Application (Client) ID | 11d892e3-e20b-4fb2-aef7-6f9b37d02cb6 |
| Service Principal ID | c9e9f0f2-739d-4526-8315-48bd0d942975 |
| Client Secret | Stored in Key Vault (TapBridge--ClientSecret) |
| Graph Permission | UserAuthenticationMethod.ReadWrite.All (application) |
| Directory Role | Authentication Administrator |
| TAP Policy | Enabled, 60-minute lifetime, single-use |
DNS Records
| Record | IP | Zone |
|---|---|---|
psi-tap-bridge | 10.160.140.6 | privatelink.azurewebsites.net |
psi-tap-bridge.scm | 10.160.140.6 | privatelink.azurewebsites.net |
AD-integrated A records on PS-AZ-DC01 for both.
Security
Authentication
The function uses Function-level key authentication (AuthorizationLevel.Function). The Android app must include the function key as a query parameter.
| Setting | Value |
|---|---|
| Function Key | Stored in Azure (retrieve via portal or az functionapp keys list) |
| Network | Private endpoint only — device must be on corporate network |
Secrets Management
All secrets use Key Vault references — no plain text in app settings:
| App Setting | Key Vault Secret |
|---|---|
ENTRA_CLIENT_SECRET | @Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=TapBridge--ClientSecret) |
AzureWebJobsStorage | @Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=TapBridge--StorageConnection) |
Group Restriction
The function only creates TAPs for users who are members of specific Entra groups:
| Group | Object ID | Purpose |
|---|---|---|
| DG - 125 - Process Services | 4e94ac7d-7993-451c-a079-3983edfaa058 | Production floor workers |
| TAP Test | be051cc3-4600-4945-9456-15c662a5d5f5 | Testing/development |
Users not in either group receive a 403 Forbidden response. Group IDs are hardcoded in CreateTap.cs.
API
POST /api/create-tap
Creates a one-time Temporary Access Pass for the specified user.
Auth: Function key required (?code=<key>)
Request:
{
"upn": "adevereaux@progressivesurface.com"
}Success (200):
{
"tap": "A9eM#FKy",
"lifetime": 60
}Errors:
| Status | Meaning |
|---|---|
| 400 | Missing or empty upn |
| 403 | User not in an allowed group |
| 500 | Graph API error (check logs) |
| 502 | Graph returned null TAP |
Deployment
Build and Deploy
The function runs on Linux but is built on Windows. Key requirement: use 7-Zip (not PowerShell Compress-Archive) to create the deployment zip — PowerShell creates zips with backslash path separators that Linux Kudu cannot extract.
cd functions/TapBridge
dotnet publish -c Release -r linux-x64 --self-contained false -o ./publish
cd publish
"C:/Program Files/7-Zip/7z.exe" a -tzip ../deploy.zip .
cd ..
az functionapp deployment source config-zip \
--resource-group PS-WEBAPPS \
--name psi-tap-bridge \
--src deploy.zipApp Settings
| Setting | Value |
|---|---|
FUNCTIONS_EXTENSION_VERSION | ~4 |
FUNCTIONS_WORKER_RUNTIME | dotnet-isolated |
SCM_DO_BUILD_DURING_DEPLOYMENT | false |
ENTRA_TENANT_ID | a83ae943-0a50-49cc-83c3-479b7a44b7fb |
ENTRA_CLIENT_ID | 11d892e3-e20b-4fb2-aef7-6f9b37d02cb6 |
ENTRA_CLIENT_SECRET | Key Vault reference |
AzureWebJobsStorage | Key Vault reference |
Infrastructure (ARM Template)
The function app was created via ARM template (functions/TapBridge/arm-deploy.json) because az functionapp create does not support --public-network-access false, and the subscription has a Deny policy on public App Services.
Known Issues
- PowerShell Compress-Archive: Creates zips with backslash separators — Linux Kudu extracts only root-level files (e.g.,
host.json). Use 7-Zip instead. - Consumption plan: Does not support private endpoints. Must use a dedicated App Service plan.
- AzureWebJobsStorage: Required even for HTTP-only triggers on dedicated plans — the function host won’t discover functions without it.
- WEBSITE_CONTENTSHARE / WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: Not needed on dedicated plans and can cause mount failures if storage isn’t VNet-accessible. Remove them.
TODO
- Rotate client secret (original is in git history) and update Key Vault
- Scope Authentication Administrator role via Administrative Unit (currently directory-wide, mitigated by code-level group check)
-
Set up CI/CD GitHub Actions workflow— done, publishes to Managed Google Play -
Configure Android app with function URL and key via Intune managed restrictions— done,tap_bridge_urlandtap_bridge_keydelivered viaRestrictionsManager -
Validate OEMConfig ACCESSIBILITY_SERVICE_ACCESS grant on fleet— not viable (Device-Owner-only on MX15+; PSI can’t be DO). Accessibility is enabled via a one-time ADB staging step instead — see “TAP injection mechanism”. -
On-device validation of the injection path on MX 15.2— done 2026-07-16 on a clean-enrolled A14 TC53E (v2.14.26): badge → TAP inject → PRT → Teams/Outlook SSO. - Fold the accessibility staging into the fleet device-staging playbook: OEMConfig
UsbMgr/UsbADBUsage=1+scripts/enable-accessibility.ps1per device (seedoc/accessibility-staging-runbook.md) - (Optional) Pursue Zebra platform-signing / ISV status for PSI Logon to remove the one-time ADB staging step entirely
- Eventually re-enable MHS sign-in with
configure_app_access_without_sign_infor PSI Logon
Last updated: August 19, 2026