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), and build-android.yml hard-fails an explicit version_code below 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

ComponentLocationPurpose
PSI Logon (NFC Launcher)app/Android app — orchestrates auth, NFC, and broker sign-in
TAP Bridgefunctions/TapBridge/Azure Function — creates TAPs via Graph API
TapInjectorServiceapp/.../service/Accessibility service — injects TAP into broker password field
Microsoft Managed Home ScreenIntune configEnterprise kiosk launcher — app grid for operators
Microsoft AuthenticatorDeviceBroker 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

StepWhat Happens
Sign inOpen PSI Logon from MHS → enter PIN → tap badge → green check with name (2s) → auto-return to MHS
Use appsFrom MHS, tap Teams, PS MES, or Edge — SSO via PRT, no additional login
Sign outOpen PSI Logon → tap Sign Out → return to MHS
Wrong PINRed “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: getCurrentAccountAsync fires both onAccountLoaded and onAccountChanged on shared devices — guarded with a resumed flag 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-play action

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 nullcom.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 reached tap_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 ended outcome=error | cancelled — the badge-prompt timeout, a third distinct mode.
  • The bridge really was sleeping, but that wasn’t this error. psi-tap-bridge had alwaysOn: false on 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. alwaysOn is now true; 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:

SettingValuePurpose
enable_mhs_signinfalseNo MHS sign-in — PSI Logon handles auth
exit_lock_task_mode_code1234IT admin escape hatch
screen_orientation1Portrait lock
grid_size4;24 rows x 2 columns
icon_size2Large icons for gloved hands
lock_home_screentrueOperators can’t rearrange
theme2Dark theme for shop floor
top_bar_user_name_styleShow bothName + email in top bar
enable_show_organization_logotrueSquare “Pro” logo

App Allowlist

AppPackage
PSI Logoncom.psi.nfclauncher
PS MEScom.progressivesurface.mes
Microsoft Teamscom.microsoft.teams
Microsoft Edgecom.microsoft.emmx

Azure Resources

ResourceValue
Function Apppsi-tap-bridge
Resource GroupPS-WEBAPPS
App Service Planasp-erp-migration-tool (B3 Linux, shared)
Runtime.NET 8 Isolated (DOTNET-ISOLATED|8.0)
Private Endpointpsi-tap-bridge-pe (10.160.140.6, PS-ProdData)
Public AccessDisabled
Storagepsargostorage (shared, for Azure Functions host)
Managed IdentitySystem-assigned (Key Vault access)

Entra ID Configuration

ResourceValue
App RegistrationPSI TAP Bridge
Application (Client) ID11d892e3-e20b-4fb2-aef7-6f9b37d02cb6
Service Principal IDc9e9f0f2-739d-4526-8315-48bd0d942975
Client SecretStored in Key Vault (TapBridge--ClientSecret)
Graph PermissionUserAuthenticationMethod.ReadWrite.All (application)
Directory RoleAuthentication Administrator
TAP PolicyEnabled, 60-minute lifetime, single-use

DNS Records

RecordIPZone
psi-tap-bridge10.160.140.6privatelink.azurewebsites.net
psi-tap-bridge.scm10.160.140.6privatelink.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.

SettingValue
Function KeyStored in Azure (retrieve via portal or az functionapp keys list)
NetworkPrivate endpoint only — device must be on corporate network

Secrets Management

All secrets use Key Vault references — no plain text in app settings:

App SettingKey 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:

GroupObject IDPurpose
DG - 125 - Process Services4e94ac7d-7993-451c-a079-3983edfaa058Production floor workers
TAP Testbe051cc3-4600-4945-9456-15c662a5d5f5Testing/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:

StatusMeaning
400Missing or empty upn
403User not in an allowed group
500Graph API error (check logs)
502Graph 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.zip

App Settings

SettingValue
FUNCTIONS_EXTENSION_VERSION~4
FUNCTIONS_WORKER_RUNTIMEdotnet-isolated
SCM_DO_BUILD_DURING_DEPLOYMENTfalse
ENTRA_TENANT_IDa83ae943-0a50-49cc-83c3-479b7a44b7fb
ENTRA_CLIENT_ID11d892e3-e20b-4fb2-aef7-6f9b37d02cb6
ENTRA_CLIENT_SECRETKey Vault reference
AzureWebJobsStorageKey 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_url and tap_bridge_key delivered via RestrictionsManager
  • 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.ps1 per device (see doc/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_in for PSI Logon

Last updated: August 19, 2026