appstrax_services 1.1.0
appstrax_services: ^1.1.0 copied to clipboard
A library to integrate with Appstrax Services, an authentication, database and file storage service.
1.1.0 #
Microsoft Entra ID sign-in, several SSO accounts per provider, and domains that must use SSO.
New #
SSO.microsoft.SsoLoginOptions.accountKey— which account to sign in through when an install has more than one for a provider. Omitting it keeps working: the API falls back to the provider's default account.AppstraxAuth.discoverSso(email)— asks which account, if any, an address must use. The shape of a "type your email, then we send you to your organisation" sign-in screen.AppstraxAuth.getSsoAccounts()— the accounts an install offers, for rendering buttons.SsoRequiredException— raised for every email/password call an enforced domain makes, carrying the account to use so an app can redirect rather than leaving the user at a dead end.
Source-breaking for exhaustive switches #
SSO gained a member. A switch over SSO with no default clause will stop
compiling until microsoft is handled. Nothing else changes: every existing
signature, field and return type is untouched, which is why this is a minor
release.
Use SSO.tryParse when reading a provider id from the API — it returns null
for a provider this build predates rather than throwing, so a server that adds
one later does not take your sign-in screen down.
Requires #
Appstrax API with multi-account SSO support.
0.0.1 #
- Initial Release
0.0.2 #
- Bug fixes -
0.0.3 #
- Bug fixes -
0.0.4 #
- Bug fixes - logout would fail if the user's account had been removed.
0.1.0 #
- Update to use the latest combined appstrax services
0.2.0 #
- Added Google SSO -
appstraxAuth.loginWith(SSO.google),appstraxAuth.linkSsoAccount(...)andappstraxAuth.getSsoProviders(). Sign-in opens in the system browser and returns to your app over a verified https link. Requires iOS 17.4+ / macOS 14.4+; Android API 21+. - Google SSO binds its one-time code to your app using PKCE, so an intercepted callback cannot be redeemed by anything else.
- Breaking: requires Dart 3.5.0 or later.
- Breaking:
TwoFactorAuthDtonow exposes an optionaltokeninstead oftokens, matching what the API actually returns. Nothing reads it, so an API that stops echoing the access token back no longer breaks enrolment. - Bug fixes - two factor authentication, email verification and enabling or disabling two factor auth sent the verification code as a malformed request body and could never succeed.
- Bug fixes -
verifyEmailAddress,enableTwoFactorAuth,disableTwoFactorAuthenticationandsaveUserDatamisread the API response as tokens rather than a user, discarding the updated user. - Bug fixes - a pending SSO link no longer expires the moment the app checks
whether the user is signed in.
linkSsoAccountcan now be retried after a wrong password, as documented. - Bug fixes - cancelling or failing a Google sign-in no longer signs out the user who was already signed in. Their session is only replaced once the provider has returned successfully.
- Bug fixes - the SSO endpoints no longer send, refresh, or clear the session
they do not need, via a new internal
skipAuthHeaderrequest option. - Bug fixes - a response that is not a user is rejected instead of being cached
and served by every later
getUser()call, andgetSsoProvidersreports an unreadable response rather than silently claiming no providers are enabled. SsoCallbackMismatchExceptionis now exported, so a redirect mismatch can be caught by type.- Bug fixes - several user endpoints were built with a missing path separator and could never be reached.
- Bug fixes - every database request was built with a doubled path separator
(
/api/database/collection//<collection>) and could never be reached.
0.3.0 #
Addresses every finding from the August 2026 code audit. The auth layer was sound; almost all of these are in the database, storage and HTTP layers.
Breaking #
- Signing in again is required once, on upgrade. Secure storage no longer
runs on the plugin's defaults. On Android the refresh token moves to
EncryptedSharedPreferences, so a token written by an older version cannot be read and the user signs in once more. On Apple platforms the keychain item is nowfirst_unlock_this_device, which stops a session outliving the app it belongs to - a reinstall no longer resumes the previous user's session, and a session no longer restores onto a different phone. apiUrlmust use https.initializeAppstraxServicesrejects a cleartext URL, since every request carries a password or a token.localhost,127.0.0.1,::1and10.0.2.2are exempt for local development.appstraxStorage.deleteFileonly accepts URLs on your API's origin. The request carries the signed-in user's token and your API key, and download URLs are usually read back out of the database, so one written by another user must not be able to redirect those elsewhere.logout()now returns aboolfor whether the server acknowledged. The local session is still cleared either way, so existing calls keep working.Utils.pathJoinand the emptyMathclass are gone. Neither worked:pathJoin's regex never matched, so it collapsed no separators.
Fixed #
Operator.notEqualwas'EQUAL', so every not-equal query silently returned the opposite result set.- A document field named
idoverwrote the real document id, so a model could point at the wrong record andsave()would write back to it. The same applied tocreatedAtandupdatedAt. - A file that grew while it was being uploaded was silently truncated and reported as a successful upload. It now fails with an explanation.
uploadFileignored the HTTP status, so a 401 surfaced astype 'Null' is not a subtype of type 'String'and an HTML error page as aFormatException.- Collection names, document ids and storage folders are encoded before going
into a URL. A value containing
&,#,?or../used to rewrite the request. - Requests now time out after 30 seconds, configurable via
initializeAppstraxServices(timeout: ...). A stalled connection used to leave the caller waiting forever. - Any
2xxcounts as success.201 Createdand204 No Contentwere treated as failures. 404and429raiseNotFoundExceptionandRateLimitedExceptionrather than being reported asInternal Server Error.- Malformed responses are reported as such instead of throwing a
TypeErrorfrom inside the SDK -FindResultDto, the uploadResponseand JWT decoding all check before they cast. - A JWT with no
expclaim no longer throwsNoSuchMethodError. Utils.generateRandomStringusesRandom.secure().- The
Access-Control-Allow-Originrequest header is gone. It is a response header and did nothing.
Added #
- Typed errors.
AppstraxExceptionand friends -NotAuthenticatedException,TokenExpiredException,MissingSsoLinkTokenException- replaceException('some prose'), and HTTP errors carry acodeholding the API's machine-readable reason (AuthErrors.invalidEmailOrPasswordand so on). The messages are unchanged, so anything matching ontoString()keeps working. initializeAppstraxServices(httpClient: ...)takes your ownhttp.Client, which is where certificate pinning, a proxy or request logging goes.- Retries. Failed connections and 502/503/504 are retried with exponential backoff, on reads and the token refresh only - a write is never replayed, and 429 is not retried because the API's rate limit windows run to an hour.
- Every service takes its dependencies as arguments, so the SDK can be tested
without a server. The suite is now split into
test/unit(offline, run by CI) andtest/integration.
1.0.0 #
Addresses the remaining findings from the August 2026 audit, and stabilises the API.
If you are upgrading, you are coming from 0.1.0. 0.2.0 and 0.3.0 were written but never published, so their entries above apply to you as well - Google SSO arrived in 0.2.0, and the bulk of the audit fixes in 0.3.0. Because nothing was released between, every breaking change the audit called for is collected here in one release rather than spread over a deprecation cycle.
Breaking #
-
Signing in again is required once, on upgrade. On Apple platforms the keychain item is now
first_unlock_this_device, so a session no longer outlives the app it belongs to or restores onto a different phone. On Android the token moved toflutter_secure_storage11's cipher storage, which does not read what 9.x wrote. Either way a token from an earlier version is not found, and the user signs in again - once.(0.3.0 said this would land in
EncryptedSharedPreferences. That backend was deprecated by Google and removed from the plugin before this release shipped, so it never did - see the dependency note below.) -
flutter_secure_storage9 → 11, which moves three floors. Dart 3.5 → 3.8, Flutter 3.24 → 3.32, and AndroidminSdk21 → 24. The Dart bound is what pub enforces; the Flutter bound is stated so a consumer gets a clear resolution error rather than a confusing SDK one, andminSdkcomes from the plugin's own Gradle configuration, so an app below 24 will fail its Android build.This is the audit's highest-value dependency finding. 9.x rested on Jetpack Security's
EncryptedSharedPreferences, which Google deprecated, and pulledflutter_secure_storage_macos, which is now marked discontinued on pub.dev. The^9.2.4constraint also blocked a consuming app from resolving 10 or 11 at all, so an app that needed the newer plugin for any other reason could not use this SDK. Both discontinued packages drop out of the resolution entirely,js0.6.7 along with them.The Android ciphers are now stated in
SecureStorageServicerather than inherited - AES-GCM for the data, RSA-OAEP to wrap the key. They match the plugin's defaults, but a default is something the plugin may change, and this is the one credential in the package. -
A 403 no longer raises
UnauthorizedException. It raises the newForbiddenExceptioninstead. Anyone catchingUnauthorizedExceptionto mean "the session expired, show the login screen" must addon ForbiddenException- and should, because the two call for opposite responses.The database's collection policies are what made the shared type untenable. A 401 means the session is over and signing in again fixes it; a 403 means the session is perfectly good and the caller is not permitted to do this, so signing in again accomplishes nothing. Sharing a type sent users to a login screen for a button they were never allowed to press.
-
LoginDto.rememberis gone, and signing in always keeps the session.LoginDto(email: ..., password: ..., remember: false)no longer compiles, and the flag is no longer sent to the API.This is a behaviour change as well as a compile break.
remember: falsewas the only way a session did not survive a restart: it kept the refresh token in memory and never wrote it to secure storage. A user who signs in now stays signed in until they sign out or the refresh token expires - including on a shared device, wherelogout()is now the only way to end a session early. -
An HTTP error no longer puts the response body in
toString(). It readsInvalid Request: invalidEmailOrPasswordrather than the body verbatim.This one is worth reading twice, because the old behaviour was a quiet leak. An API is free to echo a rejected request back in a validation error, and logging a caught exception -
catch (err) { log('$err'); }, the pattern this README recommended - therefore wrote whatever was submitted into your crash reporter, passwords included. The body is still onerr.body, now alongside a newerr.status, anderr.coderemains the supported way to branch. -
A cleartext
apiUrlis rejected in release builds, loopback included. Thelocalhost/127.0.0.1/::1/10.0.2.2exemption is now conditional onkDebugMode.10.0.2.2is only "the Android emulator's host" by convention - it is otherwise a routable private address, so a shipped app pointed at it sent passwords in the clear over whatever network it was on. -
FindResultis deleted. Nothing in the package produced it, and its type parameter did nothing -datawas hardcoded toList<User>, soFindResult<Person>compiled and handed back users. It existed to serve anappstraxUsersservice the README documented and the package never shipped. -
FindResultDtois no longer generic. Drop the type argument.datawas aList<DocumentDto>whateverTwas, so the parameter was a promise the class did not keep. UseCrudService<T>to get typed models back. -
SsoCancelledExceptionandSsoCallbackMismatchExceptionnow extendAppstraxException. They implementedExceptiondirectly, soon AppstraxException- the obvious way to catch everything this SDK raises - silently missed a cancelled sign-in, the most common of the lot. Both now carry acode. TheirtoString()gains theException:prefix as the price of the shared base class. -
Utilsis replaced byshared/jwt.dart.Utils().decodeToken(t)becomesdecodeToken(t)andUtils().isTokenExpired(t)becomesisTokenExpired(t);decodeTokennow returnsMap<String, dynamic>rather thandynamic.generateRandomStringandsleepare gone - nothing in the SDK used them. -
The package declares
platforms: android, ios. It importsdart:iofor file upload, from a file every service depends on, so it has never compiled for web - pub simply did not say so, and a web developer found out via a compile error inside someone else's code. Desktop is untested rather than broken.
Fixed #
-
A refresh whose response is lost no longer signs the user out. The token refresh was retried, on the reasoning that a dropped packet should not end a session. It had the opposite effect: the API rotates refresh tokens, so a replay after a lost response presents a token the server has already spent, which comes back
401, which means the session is over. It is no longer replayed, so the refresh fails cleanly and the stored token survives to be tried again. -
A refresh that does not rotate the token is reported instead of stored. Only a non-null refresh token was ever written, so a response that omitted one left the spent token in secure storage - and the next refresh then failed
401and signed the user out, two requests after the actual cause. -
A malformed response names the endpoint instead of throwing a
TypeError. 0.3.0 set out to remove that whole class of defect and left three parsers outside the net: a200from/loginmissingtokenstill raisedtype 'Null' is not a subtype of type 'String'from inside the SDK.Tokens,SsoProviderInfo,TwoFactorAuthDto,Message,DocumentDtoandUserare now hand-written against checked readers. (strict-castscannot catch these - it forbids implicit casts, and every one of them was explicit.)Useris included because being all-nullable protected it from the missing-field failure but not from two others:rolescast each element, so one number in the list threw, andcreatedAtwas parsed without checking it was a date. It is the most frequently parsed response in the SDK, so it was the last place worth leaving an unchecked read. -
A
2xxresponse that is not JSON is reported as a failed request. A captive portal answering200with an HTML page raisedFormatException: Unexpected character (at character 1), naming neither the status nor what arrived. -
An upload can no longer hang forever. The timeout was attached to sending the request, which resolves as soon as response headers arrive; the read that followed had no ceiling at all. Uploads also no longer share the 30-second request budget - see
uploadTimeout. -
The refresh token is sent once per request, not twice. It went out in a
Cookieand in the JSON body, which is two chances for a proxy log or a crash dump to capture it, for no gain:x-auth-transport: bodyalready tells the API which to read. -
Using the SDK before initializing says so. It raised
Invalid argument(s) (baseUrl missing, please initialize.): Must not be null- which states the opposite of the problem, since nothing was null. Now aStateErrornaminginitializeAppstraxServices. -
A write conflict no longer reports as an internal server error. The database answers 409 when a document changed between the policy check and the write, and 409 had no case in the status mapping - so it fell through to
FetchDataException, whose prefix isInternal Server Error:. It now raisesConflictException, which is what it is: the request was fine and can be retried once the caller has re-read the document. The SDK does not retry it, because resolving a conflict means deciding what the record should now contain. -
A saved token that cannot be read no longer bricks startup.
SecureStorageService.initialize()sat one layer above the catch-all inAppstraxAuth.initialize()and had none of its own, so a platform that could not decrypt what it found failedinitializeAppstraxServices- and because the value stayed in storage, it failed on every later launch too, with no way out but reinstalling. Unreadable is now treated as absent and the value is cleared.The
flutter_secure_storageupgrade is what made this worth guarding rather than theorising about, but the platform has always had its own reasons: a KeyStore entry invalidated by a new fingerprint, ciphertext restored onto a device whose key did not travel with it, a locked keyring. -
A failed session restore is no longer silent. It still cannot throw - an exception there fails app startup permanently, because the offending value stays in secure storage - but it now reports to
onRestoreErrorand to the debug console. -
The user endpoints no longer make two extra round trips.
changePassword,saveUserData,verifyEmailAddressand four others each opened withisAuthenticated(), which refreshes the token and fetches/session. Three sequential requests for one action, and none of them read the user. -
An SSO failure keeps the provider's wording off the message. The callback's
errorquery parameter became the exception message an application would show. It is now onSsoRedirectException.providerError, with fixed text inmessage.
Added #
-
ForbiddenException(403) andConflictException(409), described above. -
Operator.contains, which sendsCONTAINS. The API has supported it for some time and this SDK was the only place it could not be expressed. Not the same question asOperator.qIn:INasks whether a scalar field is one of several values,CONTAINSasks whether an array field holds one. -
uploadTimeoutoninitializeAppstraxServices, defaulting to five minutes. Thirty seconds is generous for a JSON call and far too short for a large file on a mobile connection - this package's own test fixture is a 10 MB video. -
onRestoreErroroninitializeAppstraxServices, for the startup failure that is not allowed to throw. -
disposeAppstraxServices(), which releases the HTTP client and clears in-memory session state without signing the user out. Optional in an app that runs until the process ends; necessary anywhere the SDK is initialized more than once - a hot restart, a test suite, switching API. Previously each initialize leaked the last client and left the previous session's user in memory. -
HttpException.status, the HTTP status as a field. It was either absent or smuggled intoFetchDataException's message text. -
Codes on every SDK-raised error, so
err.codeworks for the SSO failures as it already did for the session ones.
Notes on the database API #
None of these need a change in your code; they are behaviour on the API's side that this SDK now documents rather than lets you discover.
-
Collections are registered, not created by writing to them. Writing to a name nobody registered answers 404 instead of creating the collection. Existing installs are unaffected - the API registers every collection that already holds data with a permissive legacy policy when it boots, so an upgrade changes nothing until an administrator authors a real policy.
-
find()returns at most 1000 documents, whether or not a limit is given. An absent limit used to mean no limit at all; it is now the ceiling. A larger limit is clamped rather than refused, and nothing is hidden by that:FindResultDto.limitreports the limit actually applied andcountis the total you are allowed to read, socount > data.lengthmeans page withoffset. -
A document may arrive with fields missing, where a policy allows the document but not every field on it.
DocumentDto.datais a map and copes; aCrudServicemodel that declares those fields as required will not. -
CrudService.save()cannot be used with a collection that restricts writable fields. It sends the whole model, and the API rejects any field the policy does not allow the caller to write - including a field sent back exactly as it was read, since the write mask is checked before immutable fields are restored. Such a model must declare only writable fields, or useappstraxDb.edit()with just the fields being changed.
Internal #
-
Dev dependencies current:
flutter_lints5 → 6,build_runner→ 2.15.1. The two lints flutter_lints 6 adds -use_null_aware_elementsandunnecessary_underscores- are applied rather than suppressed. -
The
testdev dependency is gone. One integration file importedpackage:testwhile the seventeen around it usedflutter_test, which is a mismatch worth removing on its own -package:testprovides no Flutter binding, and that file needs one. It had also begun to conflict with thetest_apiversionflutter_testpins. -
avoid_dynamic_callsis enabled.strict-castscatches an implicit cast, but reaching into adynamicis a dynamic call - and since every response body enters this package asdynamic, that was the larger half of the same problem. -
The database, storage, CRUD and SSO-browser layers have unit tests for the first time. They were reachable only through
test/integration, which needs a live API and so never runs in CI - and they are exactly the layers 0.3.0 rewrote. The offline suite went from 140 tests to 201, and those four files from no coverage to 100%, 100%, 77% and 100%. -
SsoBrowseris exercised through the plugin's own method channel, so a cancelled sheet and a callback that arrived at the wrong address are now testable without a device.