Contents
This is written for people who run Entra ID and Intune. It assumes you know what Windows LAPS, delegated permissions and PIM are, and it is more interested in the decisions and the mistakes than in the feature list. Where I state what Microsoft Graph does, I have checked it against the reference documentation or against a live tenant, and I say which. Where I was unsure, I say that too.
1. The problem
You are standing at a Windows machine that will not log in. The local administrator password is in Entra, backed up by Windows LAPS. The admin center is open on a laptop somewhere else, behind a lock screen, possibly on another floor. Your phone is in your pocket, and your account already holds the role that can read the password. The gap between those two facts is the whole product.
LAPSlock is a native iOS app that, using the administrator's own delegated permissions, searches every managed device in the tenant, reveals the current LAPS password and the password history Graph returns with it, reveals BitLocker recovery keys per volume, requests just-in-time PIM activation when the role is eligible rather than active, and lets a managed service provider switch between customer tenants. Each reveal is a live Graph call under the user's own token, and it lands in the tenant's audit log exactly as a read from the portal would. The app grants nothing. Without a directory role that can read LAPS passwords, it cannot read them either.
The password history turned out to matter more than I expected. A device that stopped checking in weeks ago is still on an older password, and that is precisely the machine you are standing at.
One sentence on the business model, because the design depends on it: there is a free tier metered on the device, Apple subscriptions, and tenant-keyed organisation licences. Nothing in that model required a server to see a credential, and the architecture was shaped to keep it that way.
2. Architecture, and whether there is a backend
Modules as the security design
The app is one Xcode target plus a Swift package split into modules whose boundaries are the security design. The module that touches passwords, CredentialKit, imports Foundation and the authentication protocol and nothing else. Adding a logging framework, an analytics SDK, a crash reporter or the licensing layer to it is a compile error, enforced twice: by the package manifest's dependency lists, and by a shell script that greps the imports and fails the build on any forbidden module. That script runs before every commit. The licensing and subscription modules are forbidden from importing CredentialKit in the other direction, so a receipt and a local administrator password never share a link graph.
| Module | Role | Allowed imports |
|---|---|---|
AuthKit | Auth protocol and models, the tenant pin, consent helpers, Graph request-id tracer | Foundation |
AuthKitMSAL | The only module that links MSAL; its implementation is entirely inside #if os(iOS) | AuthKit, MSAL |
CredentialKit | LAPS and BitLocker providers, the SensitiveValue type | Foundation, AuthKit |
InventoryKit | Device list, paging, search, background fill, favourites, optional user names | AuthKit, CredentialKit types |
PrivilegedAccessKit | PIM eligibility, policy, activation, claims-challenge parsing | Foundation, AuthKit |
LicensingKit | Entitlement token verification, the reveal meter, keychain stores | Foundation, CryptoKit, Security |
SubscriptionKit | StoreKit 2 catalogue and entitlement merge | Foundation, StoreKit, LicensingKit |
DiagnosticsKit | The support report, structurally unable to carry a credential | Foundation |
PlatformSecurity | Biometric gate, reveal window, app-switcher cover, screenshot detection, clipboard | Foundation, UIKit, LocalAuthentication |
MSAL arrives as a checksum-pinned binary target, Microsoft's own signed XCFramework. It is the only third-party dependency in the app, and the credential path's link graph contains no third-party binary at all.
One reveal, start to finish
- The user taps Reveal on a device.
- The free-tier meter is checked. It is a ledger of reveal timestamps in the device Keychain. If it is exhausted the user is told now, before anything else happens.
- The biometric gate runs:
LAContextwith.deviceOwnerAuthentication, so Face ID, Touch ID or the passcode. A cancelled prompt stops here. No network call, no audit event in the tenant. - MSAL acquires a token silently for the required scope against the pinned tenant authority. The token's
tidis compared to the tenant the app deliberately selected. - One Graph GET over an ephemeral
URLSession: no cache, no cookie store. - The response is parsed with
JSONSerializationinto aSensitiveValue, a final class that is notCodable, has no value-exposing description, and wipes itself on deinit. - The meter is charged, now that the fetch has succeeded. Checking before the gate and charging after the response are two separate calls on purpose: nobody completes Face ID only to learn they are out of reveals, and a cancelled prompt costs nothing.
- The credential is published to the screen only once the scene is active, so the app-switcher cover can never race it. A sixty-second window starts. On timeout, hide, backgrounding or screen-capture detection, the value is wiped.
Is there a backend?
In the credential path, no. The app talks to exactly three hosts, and the third only in one circumstance.
| Host | When | What crosses the wire |
|---|---|---|
login.microsoftonline.com | Sign-in and refresh | Standard Entra delegated authentication through MSAL |
graph.microsoft.com | Browsing, reveals, PIM | Graph requests carrying the user's delegated token |
| Kainor's entitlement endpoint (Azure Functions) | Only after an organisation licence is activated, then roughly monthly | A version number and the tenant GUID. It returns a signed tier. |
A free-tier install or an Apple-subscription install never contacts Kainor. The entitlement endpoint never receives a Microsoft token, a user, a device or a credential, and it cannot act in anyone's tenant because it holds nothing that could. Its request and response format, and what the server does and does not log, are published as a contract in the repository, and the network transparency document is a ten-minute proxy recipe for checking all of this yourself. iOS has no practical reproducible builds, so observed network behaviour is the verifiable claim, and the published source explains the binary rather than proving it.
3. Authentication
Delegated, not application permissions
This was the first decision and everything else follows from it.
- Authorization stays Microsoft's. The app can read what the signed-in account can read in the admin center and nothing more. An application permission would mean an app identity holding tenant-wide read over every LAPS password, with a vendor somewhere holding the secret that unlocks it.
- The audit trail is the tenant's. A delegated read is attributed to the human and appears in the Entra audit log like a portal read. An application-permission read is attributed to the app.
- There is nothing to steal from the vendor. The app is a public client using PKCE. There is no client secret and no certificate, so there is no credential whose theft would open every customer.
- Microsoft forces it for BitLocker anyway. Retrieving a BitLocker recovery key value is delegated-only. Application permissions are not supported for it.
- No human present, no credential. Every reveal requires an unlocked phone, a biometric check and a current token.
How MSAL is configured
- One multi-tenant app registration, public client, PKCE, no secret. The redirect URI uses the
msauth.<bundle-id>://authform and is registered as the iOS/macOS platform. - Interactive sign-in uses the
/commonauthority with the account-selection prompt. After sign-in the app builds a tenant-specific authority for that account and uses it for every silent acquisition. The home tenant GUID, or a deliberately selected customer tenant for an MSP, becomes a pin. - The pin check (token
tidmust equal the pinned tenant) lives in the Foundation-only module, not in the MSAL module. The MSAL module compiles to nothing on macOS, so nothing inside it can be unit tested there. The single most security-relevant comparison in the auth path therefore runs on everyswift test. - The token cache is MSAL's keychain cache with the sharing group set to the app's own bundle identifier, which is MSAL's documented way to disable sharing. MSAL's implementation hard-codes
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyon every cache item and offers no knob to change it. I checked that in the source of the common library, and the constant is present in the shipped binary. So the cache is private to the app, never synced to iCloud Keychain, and readable after first unlock. That is one notch weaker than the when-unlocked class the app uses for its own items, and I would rather say so than imply otherwise. - Microsoft Authenticator is supported as the broker. Its return arrives as a URL open, which a scene-based SwiftUI app receives through
onOpenURLand never through the application delegate. MSAL validates broker responses with a nonce, so the missing source application on that path is fine. - Every MSAL call that can present UI is dispatched to the main thread. The auth manager is an actor, and MSAL dereferences the presenting view controller inside
acquireToken, not just while you build the parameters. More on how I learned that in section 7. - Session restore at launch is silent: a cached account is proven by a silent token acquisition before the app shows anything, and failure drops to the sign-in screen.
- Bring-your-own app registration, where an enterprise substitutes its own registration and the vendor disappears from the consent screen entirely, is supported in configuration (the client ID and redirect URI are injectable) and not yet exposed in the UI.
The scopes, and when each is requested
Consent is incremental. The app asks for the minimum at sign-in and adds scopes only when a feature is first used or explicitly enabled. The ReadBasic variants return LAPS and BitLocker metadata, rotation dates and which volumes have keys, without the secret. That is all the device list needs, so a user who only browses never consents to credential read.
| When | Scopes |
|---|---|
| Sign-in (read-only baseline) | DeviceManagementManagedDevices.Read.AllDevice.Read.AllDeviceLocalCredential.ReadBasic.AllBitLockerKey.ReadBasic.All |
| First reveal | DeviceLocalCredential.Read.AllBitLockerKey.Read.All |
| Rotation toggle enabled | DeviceManagementManagedDevices.ReadWrite.All |
| User display names toggle (off by default; the toggle names the permission it adds) | User.ReadBasic.All |
| PIM toggle enabled | RoleEligibilitySchedule.Read.DirectoryRoleAssignmentSchedule.ReadWrite.DirectoryRoleManagementPolicy.Read.DirectoryPrivilegedEligibilitySchedule.Read.AzureADGroupPrivilegedAssignmentSchedule.ReadWrite.AzureADGroupRoleManagementPolicy.Read.AzureADGroup |
Admin consent in other tenants
There is no LAPSlock account system. It is ordinary Entra consent. A user whose tenant permits user consent sees the Microsoft prompt the first time each scope is needed. Where tenant policy requires an administrator, Entra answers with one of AADSTS65001, AADSTS90094 or AADSTS900971. The app recognises those, explains the state in plain words, and offers a shareable admin-consent link, the standard /adminconsent URL for that tenant, together with a short message the user can send to their administrator. Approving from the link grants the organisation; approving inside the app grants the user.
For MSPs the same thing applies per customer. Each customer tenant's administrator consents once, and the technician's access there is whatever their guest, GDAP or dedicated account can already do. Switching tenants changes the pinned authority, clears the device list, discards any credential on screen, and on relaunch the app returns to the home tenant on purpose. Operating inside a customer should be a decision made now, not inherited from last week.
4. Graph API usage
Everything is v1.0 and documented unless marked beta. The credential path parses with JSONSerialization rather than Codable, because a synthesized Codable conformance drags a description and a memberwise initialiser along with it, and a credential type must have neither.
Inventory
GET /v1.0/deviceManagement/managedDeviceswith$selectand@odata.nextLinkpaging. The first page renders immediately and a background fill continues to a cap of fifty pages, about five thousand devices, honouringRetry-Afteron throttling.- The specification assumed a join between Intune's managed device and the Entra device object. It is not a join:
managedDevicesreturnsazureADDeviceIddirectly. The real work was absence. It is null or the all-zeros GUID for devices that are not Entra-joined, which cannot have Windows LAPS, so the UI says so rather than sending a request that 404s. - Server-side search was not usable.
$filteron this resource supportseqand a littlestartswith, notcontains, and$searchis unsupported. Search is client-side over the loaded set, which is why the background fill exists.
Windows LAPS
- Metadata:
GET /v1.0/directory/deviceLocalCredentials/{entraDeviceId}returns the backup and refresh dates without the secret. - Reveal: the same resource with
?$select=credentials. Thecredentialsarray carries every backed-up version, each withaccountName,backupDateTimeandpasswordBase64. The app decodes the base64, detects the string encoding (Windows LAPS values are typically UTF-16LE once decoded), sorts newest first and keeps up to ten. Password history therefore cost nothing on the wire. It was already in the response, which I verified against Microsoft's documentation and againstGet-LapsAADPassword -IncludeHistoryafter a search summary had told me, wrongly, that history was on-premises only. - Rotation is policy-driven in Windows LAPS and is not an app action.
BitLocker
- List:
GET /v1.0/informationProtection/bitlocker/recoveryKeys?$filter=deviceId eq '{entraDeviceId}'returns key metadata without the key.$topis not supported here. - Reveal:
GET .../recoveryKeys/{keyId}?$select=key. Thekeyproperty comes back only when explicitly selected, and selecting it writes a KeyManagement entry to the tenant's audit log. - Rotate:
POST /beta/deviceManagement/managedDevices/{id}/rotateBitLockerKeys, beta, behind the rotation toggle.
macOS LAPS
The only macOS-specific function is GET /beta/deviceManagement/managedDevices/{id}/retrieveDeviceLocalAdminAccountDetail. Its documented response type has exactly one property, passwordLastRotationDateTime. There is no password in the contract. In practice the function also returned HTTP 500 for every ADE-enrolled, LAPS-managed Mac I tested, on two dates four weeks apart, while the same user could view the password in the admin center. The Entra store that Windows LAPS uses returns 200 with no credentials for Macs. The conclusion is that macOS passwords are viewable only in the admin center today. The app shows the rotation date when the call succeeds, degrades cleanly when it does not, and offers rotation (POST .../rotateLocalAdminPassword, beta) behind the toggle. I have an open question with Microsoft about this on Microsoft Q&A, and two read-only PowerShell scripts in the repository reproduce the findings against any tenant.
PIM
- Eligibility is read from two surfaces, because tenants use both:
roleManagement/directory/roleEligibilitySchedules/filterByCurrentUser(on='principal')for directory roles, andidentityGovernance/privilegedAccess/group/eligibilitySchedules/filterByCurrentUser(on='principal')for PIM for Groups. - Policy is read from
policies/roleManagementPoliciesto learn the maximum activation duration, whether justification or a ticket number is required, and whether an authentication context is required. The activation sheet offers only durations the policy allows. This replaced a hard-coded duration and then a fixed picker, both wrong for the same reason. - Activation posts to
roleAssignmentScheduleRequestsor the group equivalent withselfActivate. The response status is classified into five outcomes: activated, provisioning (an ordinary delay), pending approval, requested, and refused. A pending approval must never look like success, and a provisioning delay must never look like a pending approval. Both misreadings shipped once. A Check again button polls the request by its ID without re-prompting.
Then the claims challenge. The documented form is a 401 with a WWW-Authenticate header carrying base64-encoded claims. Under a PIM policy that requires a Conditional Access authentication context, Graph answers something else entirely: 400, error code RoleAssignmentRequestAcrsValidationFailed, with the claims sitting as raw JSON inside the error message string.
RoleAssignmentRequestAcrsValidationFailed claims={"access_token":{"acrs":{"essential":true,"value":"c1"}}}
Anything watching 401 and 403 for challenges never fires, and activation dies as a bare bad request. I could not find this documented. The app parses both forms, treats the blob as untrusted input because it is going straight into MSAL (it must be a small JSON object or it is dropped), hands it over as a claims request, re-authenticates, and retries once. A plain 401 deliberately never takes that path, or you loop people behind a prompt they cannot satisfy. And when the policy pre-read already says an authentication context is required, the claims go on the first attempt and the 400 is the fallback rather than the normal path.
Optional user names and error handling
Display names come from GET /v1.0/users?$filter=userPrincipalName in (...), fifteen UPNs per request because that is the most the in operator takes, off by default because it adds a permission to every customer's consent screen. Errors are mapped to specific states with their own copy: role missing (which offers PIM if enabled), consent required (which offers the link), throttled, service unavailable, device not Entra-joined. Graph's request-id header from the last failed call is captured because it is the first thing Microsoft support asks for, and the diagnostics report carries request IDs, AADSTS codes, correlation IDs and HTTP statuses through an allowlist, never a message string, because MSAL error descriptions can carry URLs and a broker redirect URL can carry an authorization code.
5. On-device security
| Concern | What the app does |
|---|---|
| Token storage | MSAL's keychain cache in the app's private access group, this device only, after first unlock. Refresh is MSAL's and is serialised through the auth actor. A token leaves the device only to Microsoft. |
| The app's own Keychain items | Tenant list and reveal-meter ledger: kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Entitlement token: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Nothing syncs to iCloud Keychain. |
| Credential caching | None. A credential lives in a SensitiveValue in memory for at most the sixty-second window, then is wiped. Nothing reaches disk, UserDefaults, state restoration or a backup. Credential requests use an ephemeral session with no cache and no cookie store. |
| Biometric gate | .deviceOwnerAuthentication, so Face ID, Touch ID or passcode, before the Graph call. An abandoned prompt produces no audit event in the tenant. |
| Reveal window | Sixty seconds with a countdown. Re-masked and wiped on expiry, hide, backgrounding and screen capture. Published to the screen only once the scene is active. |
| App switcher | A privacy cover replaces the content in the multitasking snapshot. Verified on hardware for both credential types; two cleverer implementations failed in the dangerous direction and were reverted. |
| Screenshots and recording | iOS cannot block them. The app observes the screenshot and capture notifications, hides the credential, and recommends rotation. Stated as a limitation in the security policy rather than hidden. |
| Clipboard | Copying a credential uses UIPasteboard with .localOnly, so no Universal Clipboard handoff, and an .expirationDate ninety seconds out. The app clears the pasteboard itself if the value is still there. Copying was gated behind the paid tier for one commit and reverted the same day: a user who cannot copy will retype or photograph the password, which is worse. |
| App lock | Optional Face ID over the device list itself, because hostnames and primary users are reconnaissance for a whole tenant. Only backgrounding starts the clock, with a five-minute grace period and suppression during sign-in, so a round trip to Authenticator never locks the app mid-flow. |
| Diagnostics | In memory only, deliberately not persisted. Typed fields through an allowlist with no place for a password, a key, a device name or a user name. The user can read the whole report before sending it. |
| Telemetry | None. No analytics, no crash reporting, no account. The privacy manifest declares one required-reason API, UserDefaults, for the app's own settings. The App Store label is Data Not Collected. |
| Free-tier meter | A Keychain ledger of reveal timestamps, on the device only. It is a nudge, not DRM. A device wipe resets it, and the privacy claim is worth more than enforcement. |
6. Security decisions and the threats behind them
| Decision | Threat addressed | Residual |
|---|---|---|
| Delegated permissions only; no app identity with tenant-wide read | A vendor or attacker reading credentials without a human present | None for the app; Microsoft RBAC governs |
| No server in the credential path; three-host egress, published and proxy-verifiable | A compromised or malicious vendor backend | Licensing endpoint exposure is limited to tenant GUIDs |
| Credential module isolation enforced at build time | A future change leaking a credential to a log, SDK or the licensing layer | The guard runs on every commit; it must keep running |
SensitiveValue that cannot be serialised or described; no Codable in the credential path | Accidental serialisation, logging or UI echo of a secret | None known |
| Biometric gate before Graph; meter before the gate, charged after | A lost or borrowed unlocked phone; abandoned reveals polluting the tenant's audit log | A fully compromised device can read what its user can read |
| Sixty-second window, wipe on background and capture, switcher cover | A phone left on a bench with a password on screen; multitasking snapshots | Screenshots cannot be blocked on iOS |
Tenant pin: token tid must equal the deliberately selected tenant | Cross-tenant authorization mistakes, especially for MSPs | Very low; the comparison is unit-tested on every build |
| This-device-only Keychain classes, no sync | Token or state replication to other devices | Low |
| Clipboard local-only with expiry and self-clearing | Universal Clipboard handoff; a stale credential in the pasteboard | Low |
| Claims-challenge blob treated as untrusted input | Attacker-influenced content passed into MSAL | Low |
| Entitlement endpoint receives a tenant GUID only; the token is an ES256 JWS verified against compiled-in public keys, never a fetched key set | Backend compromise; token forgery; licence replay across tenants | A stolen licence token is useful only inside the licensed tenant |
| Reveal count on the device, never on a server | The vendor learning how often each tenant retrieves passwords | The meter is evadable by design |
| Source published for review | Blind trust in a credential-handling binary | No reproducible builds on iOS |
7. Challenges and dead ends
macOS LAPS has no API, and the one function 500s. The specification flagged this as the product-defining open question and it was settled against a live tenant on the second day of the project: no password in the contract, 500 in practice, nothing in the Entra store. macOS reveal was declared impossible rather than pending, the App Store listing says so up front, and the question is with Microsoft.
The first run on a real phone found two auth bugs the simulator cannot show. With Microsoft Authenticator installed, the broker's redirect arrived as a URL open that nothing handled, and MSAL reported that the application did not receive a response from the broker. Separately, MSAL's interactive calls were being made from an actor on the cooperative thread pool, and MSAL touches the presenting view controller inside acquireToken. Without a broker, MSAL keeps the whole flow in-process and the fragile part never runs, so the simulator was green. Both bugs produced the same generic error. I wrote two fixes before reading a log, and both were wrong; the MSAL log named the cause in one line once I captured it. The lesson stuck: for a device-only failure, read the console before writing code.
The MSAL module is invisible to the package tests. Its whole implementation is inside #if os(iOS), so a file added without that guard broke the macOS test build while the iOS app stayed healthy, and later a wrong MSAL API label passed 262 green tests and failed the iOS build immediately. The two checks cover opposite halves, and the project now runs both before any commit.
The tenant guard was wrong for MSPs. The original check compared the token's tid to the account's home tenant, which is right for a single-organisation admin and breaks an MSP operating in a customer tenant. Deleting the check would have removed the only thing stopping a token for the wrong organisation being used against it. The fix made the expectation explicit: the token must match the tenant the app deliberately selected.
Testing MSP switching and PIM needed licences and a second tenant. PIM needs Entra ID P2, which I bought for the test tenant. Switching needs a second tenant that has invited the account; building one from scratch was investigated and abandoned in favour of a guest invitation. Because the real path depends on tenant policy nobody can reproduce in a lab, each of the switcher's ten failure cases explains itself on screen.
The claims challenge that arrived as a 400, described in section 4. Activation under an authentication-context policy died as a bare bad request until the error body was parsed.
Provisioning versus pending approval. The first PIM build reported an ordinary provisioning delay as waiting for approval, in a tenant with no approval step, and sent someone looking for an approver who did not exist.
The privacy cover flashed, and two fixes exposed the credential. After Face ID the scene is still inactive when the secret is published, so the Hidden cover showed for two seconds. Two attempts to track the transition more precisely both failed on hardware by showing the credential in the app switcher. The condition that errs toward hiding stayed, and the real fix was elsewhere: publish the credential only once the scene is active.
A search regression you could feel. Widening search used range(of:options:) with case and diacritic insensitivity, which performs full Unicode folding on every call, and called a ranking function from inside the sort comparator. On a tenant of a few hundred devices that was on the order of a hundred thousand folds per keystroke. Fold each field once, score once, sort on the precomputed rank.
Search only covered loaded pages. On a tenant with several pages of devices, a device that existed was not found. Hence the background fill.
Three bugs a 262-test suite did not catch surfaced in one afternoon on hardware: licence state leaking across tenants, no sign-out button anywhere in the UI, and an Activate button that did not name the organisation it would bind to. A green suite is not proof.
@Published emits in willSet. A sink that re-read the property saw the old value, so buying the MSP subscription did not show the tenant switcher until relaunch. Use the value the closure hands you.
Two subscription groups became one. Separate groups would have allowed double-paying, two free trials and no upgrade path.
App Store review rejected the first submission for a missing EULA link in the description. Guideline 3.1.3 shaped the website more than the app: the support page the app links to carries no pricing and no purchase links. iPad was removed from 1.0 rather than shipped as a stretched phone app, because the reveal path's scene-phase behaviour under multitasking needs its own verification on an iPad.
8. Timeline
From the public repository's history: 153 commits, first on 13 August 2026, release on 8 September 2026. The licensing backend lives in a separate repository and is not counted.
| Date | Milestone |
|---|---|
| 13 Aug | First commit. |
| 14 Aug | Security core (SensitiveValue, providers, auth seam, isolation guard), MSAL sign-in verified end to end, inventory with paging and search, biometric gate and reveal window, consent onboarding, device list and gated reveal. macOS LAPS verified impossible the same day. |
| 17 Aug | BitLocker, settings, diagnostics export, privacy policy and terms, app icon, source-available licence. |
| 26 to 27 Aug | First run on a real phone. Broker redirect and main-thread fixes. First TestFlight builds. Entitlement backend infrastructure provisioned. |
| 28 to 29 Aug | Pricing and metering decided: free tier metered on the device, never on a server. Product renamed. Licensing module and reveal meter. |
| 1 Sep | Entitlement API contract written first, then implemented: signing key published, server retention tightened, tokens verified on device. Network transparency document and security one-pager. |
| 2 Sep | MSP tenant switcher, tenant guard re-pointed, sign-out added, Entra ID P2 purchased, switching verified. PIM: claims challenge, directory roles and groups, policy read, the 400 form, provisioning versus approval. |
| 3 Sep | Background inventory fill, optional display names, LAPS password history, Apple subscriptions, favourites and recents, session restore, app lock, support page, site moved to Cloudflare Pages. 1.0 submitted. |
| 4 to 5 Sep | Review rejection fixed. Organisation licensing automated end to end. |
| 8 Sep | 1.0 released on the App Store. Microsoft Q&A question filed. Source tagged. |
Twenty-one days from first commit to submission, twenty-six to release, with 379 package tests at release.
9. What I would do differently, and what is next
Differently
- Run on a real phone on day one, not day thirteen. Four bugs were unreachable in the simulator: demo providers reachable from the live path, MSAL off the main thread, the unhandled broker redirect, and the search regression.
- Read the log before writing code, every time a device-only failure has a generic message.
- Write the server contract before the server. The entitlement contract caught the deployed endpoint disagreeing with it and forced the retention decisions before any code existed. That worked and I would repeat it.
- Test security changes in the direction that matters first. The app-switcher regression shipped because the change was tested for the flash it fixed, not for the exposure it risked.
- Trust primary documentation over summaries. It would have found password history a week earlier and never flagged macOS as a roadmap item.
- On tooling: this was built with heavy use of AI coding tools. Every line was reviewed, every claim above was tested on hardware, and the network claim is verifiable with a proxy regardless of who typed the code. The mistakes in section 7 are mine either way.
Next
- iPad as a real 1.1 with a split view, with the multitasking behaviour of the reveal path re-verified on an iPad as a blocking test.
- Four lifecycle scenarios that are true by construction but not yet run as scripted tests: role removal mid-session, PIM activation expiry, offline behaviour, and session revocation in Entra, which without Continuous Access Evaluation takes effect at access-token expiry rather than instantly. I will write up the results.
- Showing the tenant's device count against the licensed plan, computed on the phone and never sent.
- Exposing bring-your-own app registration in the UI, so an enterprise can remove the vendor from the consent screen entirely.
- App Attest on the entitlement call, deliberately a later phase: it defends against a forged client, which the threat model rates low because the client holds nothing worth forging for.
- macOS reveal the day Microsoft ships an API. The steps to enable it are in one file.
LAPSlock is on the App Store, and the client source is published for review on GitHub under a source-available licence. The how it works page is the plain-language version of this post, including the permissions table. Corrections and findings to connor@kainor.com; the security policy explains how to report a vulnerability. Kainor LLC is not affiliated with, endorsed by, or sponsored by Microsoft Corporation.