Mobile environment enrollment
TenancyEngine mobile environment enrollment lets an ISV distribute one canonical mobile build while authorizing selected installations to use a registered development or staging environment. Google Play tracks and Apple distribution channels remain release controls; they do not select service URLs.
Security model
- A new installation uses the app's compiled production profile.
- The device first creates a random proof in OS secure storage and shares an endpoint-free
te_er1_request code containing only the application ID, registered public client ID, proof thumbprint, and issue time. This is an untrusted device request, not device attestation. It is length-bounded, expires after 15 minutes, and is rejected when its clock is more than two minutes in the future. - An application administrator pastes that request code in Applications > Environments and creates a short-lived tester enrollment bound to its thumbprint.
- TenancyEngine derives the app callback scheme from an active public OAuth client already registered to that application environment. Operators cannot enter a callback into the invitation form.
- The resulting QR contains an app deep link with an opaque, authenticated-encryption-protected invitation. The database stores only its SHA-256 hash and the device proof's one-way thumbprint.
- Custom URL schemes cannot be claimed generically and exclusively across every ISV and OS. If another app intercepts the invitation deep link, redemption still fails because that app does not hold the device proof. The invitation token alone is insufficient. The private proof is never shared with an operator or included in either QR artifact; the SDK sends it only in the redemption POST over TLS to an explicitly trusted TenancyEngine enrollment authority.
- The client SDK accepts only the app's callback scheme, expected application ID and client ID, and an explicit set of trusted TenancyEngine enrollment origins.
- Enrollment uses an SDK-owned HTTP transport with automatic redirects disabled. The public API does not accept an application transport. The redemption body cannot be replayed through
307or308redirects, and a response resolving outside the trusted enrollment origin is rejected. Before URI construction or percent decoding, the SDK bounds the full link, parameter count, and encoded values; decoded authority, token, and client values are bounded again. The public endpoint permits at most an 8 KiB body, a 4,096-character invitation token, a 256-character device proof, and a 256-character client id. - Redemption reloads and transactionally revalidates the OIDC authority, API base URL, redirect URI, and client ID from the current registered environment at the final mutation. Values are never accepted from either QR payload.
- Invitations expire, can be redeemed exactly once by the bound device, can be revoked, and are audit logged. Invitation creation audit data excludes the raw link, token, and QR.
- Before a different profile becomes active, the SDK runs every registered environment-bound resetter. Apps use these resetters to sign out and erase tokens, tenant data, queues, and caches from the old lane.
The protected token uses ASP.NET Core Data Protection with the platform's shared key ring. Staging and production therefore require the same persistent, encrypted key-ring configuration already required for TenancyEngine authentication cookies. Restarting one instance does not invalidate outstanding invitations.
Console workflow
- Open an application and choose Environments.
- Expand Mobile tester enrollment on the target environment.
- If needed, choose Add mobile client and enter the app's public callback URI, such as
com.example.product://auth/callback, plus its sign-out callback. - On the device, choose the app's Request test environment action. Share the generated
te_er1_request code with the administrator within 15 minutes. It contains no authority or API endpoint and must be treated as an untrusted request, not proof that the device or user is genuine. - Choose Create tester link, paste the request code, select its lifetime, and create it. Console validates the app and registered mobile client and creates a one-use link for that device.
- Share the one-time link or QR through a trusted tester channel. The raw value is shown only in the creation result; the persisted list shows status and usage but cannot reveal it again.
- Revoke a link immediately if it was shared incorrectly.
Every invitation is one-use and device-bound. Each additional tester or replacement installation must generate its own request code and receive its own short-lived invitation.
MAUI SDK
Reference TenancyEngine.Sdk.Maui version 0.3.0 or later. Configure the production profile in the app, then configure enrollment with immutable app identity and trusted platform origins:
var enrollmentOptions = new MobileEnvironmentEnrollmentOptions
{
ApplicationId = Guid.Parse("00000000-0000-0000-0000-000000000000"),
ClientId = "yourapp-mobile",
RedirectScheme = "com.yourapp.mobile",
TrustedEnrollmentAuthorities =
[
"https://auth.saasruntime.com",
"https://auth.stage.saasruntime.com",
"https://auth.lab.saasruntime.com",
],
};
var enrollment = new MobileEnvironmentEnrollmentClient(enrollmentOptions);
using var environmentRuntime = TenancyEngineMaui.CreateEnvironmentRuntime(
enrollment,
productionProfile,
environmentBoundResetters);
// Startup: restore and validate the selected profile before constructing screens or services.
var current = await environmentRuntime.InitializeAsync();
var auth = current.AuthClient;
var api = current.ApiClient; // BaseAddress and bearer-token handling match current.Profile.
var restoredSession = await auth.TryRestoreSessionAsync();
// Before an administrator creates the invitation, show this endpoint-free request code to the tester.
// Keep this store for redemption: it holds the private proof in OS secure storage across app restarts.
var bindingStore = TenancyEngineMaui.CreateEnvironmentDeviceBindingStore();
var binding = await bindingStore.GetOrCreateAsync();
var requestCode = binding.CreateEnrollmentRequest(
enrollmentOptions.ApplicationId,
enrollmentOptions.ClientId);
// Later, after the tester opens the Console-issued invitation:
var result = await enrollment.RedeemAsync(incomingDeepLink, binding);
if (result.Success)
{
// The old auth session is cleared and its API client is disposed before this pair is exposed.
current = await environmentRuntime.ActivateAsync(result.Profile!);
auth = current.AuthClient;
api = current.ApiClient;
var signIn = await auth.SignInAsync();
}CreateEnvironmentRuntime owns the secure profile store and reconstructs both clients from one validated profile. Supply resetters for every additional environment-bound local store. The coordinator revalidates stored profiles on startup, clears malformed or no-longer-trusted profiles after resetting bound state, and rejects an unvalidated candidate before activation. Do not retain AuthClient or ApiClient references across a transition; use the runtime returned by ActivateAsync or ReturnToProductionAsync, or read Current after the call completes.
The runtime API client also owns its nonredirecting primary transport. Optional application DelegatingHandler middleware may be supplied through apiMiddlewareFactory; it runs outside the credential boundary. The SDK validates the destination before token retrieval, clones the request, attaches the bearer only to that private outbound clone, and removes the clone from the returned response. Middleware therefore never receives the SDK bearer on either the original request or the response request-message. Middleware is trusted application code and can inspect the app-owned body, but an off-origin mutation or replay passed inward is rejected before authentication.
Startup may restore a token only from the selected authority's isolated secure-storage namespace. Activation clears the previous authority's session, so the tester signs in through the newly selected authority before making authenticated API calls.
current = await environmentRuntime.ReturnToProductionAsync();
auth = current.AuthClient;
api = current.ApiClient;The SDK deliberately has no free-form environment picker. A debug-only loopback authority can be enabled with AllowInsecureLoopbackForDevelopment; non-loopback HTTP is always rejected.
Parse is stateless and returns its parsed invitation in MobileEnvironmentInvitationResult.Invitation. Apps may parse multiple links concurrently without shared mutable state. The runtime API client also rejects an absolute request whose origin differs from the enrolled API origin before asking for or attaching a bearer token.
StudioDash compatibility impact
StudioDash must replace any use of MobileEnvironmentEnrollmentClient.ParsedInvitation with the Invitation value returned by Parse. Its tester-access screen must call CreateEnvironmentDeviceBindingStore().GetOrCreateAsync(), display the request from CreateEnrollmentRequest, retain that binding for RedeemAsync(link, binding), and offer a way to clear/regenerate it when an enrollment is abandoned. The UI must label the code as a 15-minute request, not attestation, and explain that the proof is sent only during TLS redemption to the configured trusted authority. No StudioDash service URL or business rule belongs in the platform SDK. StudioDash must construct enrollment with new MobileEnvironmentEnrollmentClient(options) and replace any apiHandlerFactory integration with apiMiddlewareFactory returning unconfigured DelegatingHandler instances. Each tester installation requires a separate request and one-use link.
Other mobile stacks
The REST contract is framework-neutral. A non-MAUI client must implement the same checks before calling POST /api/v1/mobile-enrollments/redeem: a device-generated proof bound by thumbprint when the invitation is created, exact app scheme, trusted enrollment origin, expected application ID, expected public client ID, HTTPS service URLs, callback-scheme match, and full environment-bound state reset before activation. The request artifact (te_er1_), invitation, and profile schemas are versioned; unknown versions fail closed. Never use a Google Play or App Store track name as an environment selector.
Release behavior
The production AAB or IPA always starts on production. Development and staging enrollment is authorization, not a hidden permanent preference: expose the active lane in diagnostics/settings, provide a clear return to production action, and never include arbitrary URL input. Distribution track promotion does not rewrite the app or silently move an enrolled tester between platform lanes.