ab_ecosystem_sso
Flutter SDK for AB Ecosystem SSO. Sign-in, signup, two-factor, multi-account switching, captcha and secure session storage — plus account settings, team administration and billing. Either as drop-in widgets or as a plain client you build your own screens on.
final sso = SsoClient(SsoConfig(
projectId: 'proj_123',
baseUrl: Uri.parse('https://api.ab-ecosystem.com'),
));
Future<void> main() async {
await sso.init();
runApp(SsoScope(client: sso, child: const MyApp()));
}
// Wherever your login screen goes:
SsoAuthFlow(onSignedIn: (String token) => context.go('/home'))
That is a complete login, including the 2FA step, the account picker and the project's captcha.
Contents
- Install
- Configure
- Three ways to use it
- Sessions
- Captcha
- Multiple accounts
- Account settings
- Team administration
- Billing and wallets
- Calling your own backend
- Token storage
- Permissions
- Errors
- Theming and text
- State management
- What this version does not cover
- API summary
Install
dependencies:
ab_ecosystem_sso: ^0.1.0
Requires Flutter 3.35 or newer. The package pulls in flutter_secure_storage
(keychain and keystore) and webview_flutter (captcha), so Android and iOS are
the supported targets.
Android needs minSdk 24 or higher in android/app/build.gradle.kts,
which is what flutter_secure_storage requires — and what Flutter 3.35 sets as
its own Android minimum anyway.
Configure
final sso = SsoClient(SsoConfig(
projectId: 'proj_123',
baseUrl: Uri.parse('https://api.ab-ecosystem.com'),
));
projectId and baseUrl are both required and have no defaults — the SDK is
multi-tenant, and guessing an API host is not something a library should do.
Everything else is optional: see token storage,
captcha, and SsoConfig.httpClient for a client of your own.
Then, before the first frame:
await sso.init();
init() reads the stored token and checks it against the server. Awaiting it
is what stops the app flashing a login screen at a user who is already signed
in — secure storage is asynchronous, unlike the web's localStorage.
Three ways to use it
The whole flow. One widget, every step:
SsoAuthFlow(onSignedIn: (String token) => context.go('/home'))
Individual screens, when you own the navigation:
SsoLoginForm(
onResult: handleResult,
onSignupTap: () => context.push('/signup'),
onForgotPasswordTap: (String email) => context.push('/forgot?email=$email'),
)
Also SsoSignupForm, SsoTwoFactorForm, SsoForgotPasswordForm and
SsoAccountPicker.
No widgets at all, for a login screen of your own design:
final LoginResult result = await sso.login(
email: email,
password: password,
context: context, // so a captcha can be presented if the project needs one
);
switch (result) {
case SignedIn(:final String accessToken):
// Signed in. The token is already stored.
case TwoFactorRequired():
final String code = await askForCode();
await sso.verifyTwoFactor(challenge: result, code: code, context: context);
case AccountSelectionRequired(:final List<AccountOption> accounts):
final AccountOption chosen = await askWhichAccount(accounts);
await sso.selectAccount(challenge: result, accountId: chosen.accountId);
}
A login has exactly those three outcomes, and switch makes handling all of
them a compile-time requirement rather than something to remember.
Sessions
sso.session is a ValueListenable<SsoSession> with five states. Drive your
root widget off it and every transition — signing in, signing out, a session
going stale while the app was backgrounded — lands in one place:
switch (SsoScope.sessionOf(context)) {
SsoUnknown() || SsoValidating() => const SplashScreen(),
SsoSignedIn() => const HomePage(),
SsoSignedOut() => const LoginPage(),
SsoAccountAccessLost() => const AccountPickerPage(),
}
SsoScope also re-checks the session whenever the app returns to the
foreground. That matters more on mobile than on the web: a session revoked
while the app sat backgrounded for a week would otherwise look valid until the
next request failed.
There is no token refresh
The backend issues no refresh credential, so when an access token expires the
user signs in again — there is no silent renewal to configure. init()
discards an already-expired token without a round trip, and revalidate()
catches one that died while the app was open.
Worth knowing up front: until the backend grows a refresh endpoint, every expiry means a full sign-in, captcha included. If your users are being logged out more often than you expect, that is where to look, not here.
Captcha
When the project has captcha enabled, auth calls need a token from reCAPTCHA or
hCaptcha. There is no native Flutter widget for either that works with a web
site key, so the provider's own JavaScript runs in a webview_flutter view:
visible challenges in a bottom sheet, invisible reCAPTCHA v3 behind a brief
blocking dialog.
The built-in widgets handle this with no configuration. Headless callers pass a
BuildContext so the sheet has somewhere to appear:
await sso.login(email: email, password: password, context: context);
Without a context and without a captchaToken of your own, a captcha-enabled
project throws CaptchaRequiredException.
Where the captcha comes from
Nothing has to be deployed. GET /auth-config already carries the project's
provider, version and site key, so the SDK builds the provider's page
itself and runs it in the webview.
A site key is bound to registered domains, and a page rendered from a string
has no origin of its own, so the webview claims your baseUrl as one. That
host therefore has to be on the key's allowlist — which it usually is, being
the API the project is served from.
If the key is instead bound to a web domain the API host isn't one of, serve the page from that domain and point the solver at it:
SsoConfig(
projectId: ...,
baseUrl: ...,
// A page of your own, on the domain the site key allows:
captchaSolver: const WebViewCaptchaSolver.hosted(),
// ...or keep the built-in page and claim a different origin:
// captchaSolver: const WebViewCaptchaSolver.inline(
// originBaseUrl: 'https://ab-ecosystem.com',
// ),
)
WebViewCaptchaSolver.hosted() loads
<baseUrl>/api/v1/projects/<projectId>/captcha?action=login&theme=light unless
you pass a pageUrl. Whatever serves it must post the result back on the
AbSsoCaptcha channel:
window.AbSsoCaptcha.postMessage(JSON.stringify({ token: '...' }));
// or { error: '...' }, or { expired: true }
buildCaptchaHtml is exported, so that page can be the exact one the SDK uses.
Captchas are solved at submit, not when the form opens: provider tokens expire in about two minutes, and a token fetched while the user was still typing their password is often already stale.
To use a provider SDK or a mock, implement CaptchaSolver and pass it to
SsoConfig. StubCaptchaSolver returns a fixed token, for tests.
Multiple accounts
A login can resolve to more than one account, in which case no token is issued until the user picks one:
case AccountSelectionRequired():
// SsoAuthFlow shows SsoAccountPicker.forSelection here.
The selection ticket is valid for five minutes.
For a live session, switchAccount moves to another account:
await sso.switchAccount(accountId); // or SsoAccountPicker.forSwitching
This issues a fresh token which replaces the stored one automatically. If
you keep your own copy of the token anywhere, re-read sso.accessToken after
switching — a stale copy leaves the user silently acting as the previous
account.
When access to one account is revoked
A member whose access was revoked or expired gets a 401 with code A403. That
is not a sign-out: their login is intact and their other accounts still
work. The SDK reports it as SsoAccountAccessLost rather than SsoSignedOut,
keeps the token so switchAccount still works, and the right response is to
show SsoAccountPicker.forSwitching — not the login screen.
Account settings
One screen for everything a signed-in user can change — profile, password, 2FA, WhatsApp, wallets, plans, credits, and, for an account owner, roles, members and invitations:
await showSsoAccountSettings(
context,
inviteLinkBase: 'https://app.example.com/accept-invite',
onAccountSwitch: (String token) => setState(() {}),
onLaunchUrl: (Uri url) => launchUrl(url),
);
Sections hide themselves when they don't apply. The account switcher needs
onAccountSwitch, and Roles and Invitations are owner-only endpoints,
so a team member never sees a tab that would only refuse them — the screen
reads the account context from GET /me to decide. Members shows both
sides: owners administer, members see a read-only list of their colleagues.
Every pane also stands alone, for apps that own their own navigation:
SsoProfileForm() // name, address, preferences, WhatsApp
SsoChangePasswordForm() // PUT /account, with captcha
SsoTwoFactorSetup() // enrol or disable
SsoWalletSection() // balances + transactions
SsoPlansSection() // plans and add-ons
SsoCreditsSection() // credit wallets
SsoMembersSection() // owner admin, or the member's team
SsoRolesSection() // roles + the permission matrix
SsoInvitationsSection() // create, list, revoke
SsoLogoutButton() // signs out locally even if the call fails
Pass panels: to add your own pane to the nav, and sections: to restrict or
reorder the built-in ones.
Copy for all of this lives in SsoSettingsStrings, kept separate from
SsoStrings so an app that only signs users in never has to look at it.
Two-factor enrolment
Verifying a code at sign-in is part of login and always has been. Turning 2FA on for an account is this:
final TwoFactorEnrolment e = await sso.beginTwoFactor(); // secret + QR
await sso.confirmTwoFactor('123456', secret: e.secret); // now it's on
await sso.disableTwoFactor('123456'); // needs a code too
The backend publishes two endpoint families here, and they are not aliases
— different paths, different request bodies, different responses. Both stay
reachable from one client: SsoConfig.twoFactorFamily sets the default, and
every call takes a family of its own.
SsoConfig(..., twoFactorFamily: TwoFactorFamily.account) // /2fa/*, default
await sso.twoFactorEnabled(family: TwoFactorFamily.auth); // /auth/2fa/*
SsoTwoFactorSetup takes the same argument, with a label to tell two rows
apart, so a settings screen can present both:
SsoTwoFactorSetup(family: TwoFactorFamily.account, label: 'Authenticator app')
SsoTwoFactorSetup(family: TwoFactorFamily.auth, label: 'Sign-in 2FA')
Neither affects the challenge at sign-in, which is always
/auth/verify-2fa and is part of login.
Team administration
Roles are named permission sets. The catalogue that defines what a role can hold comes from the API — never hardcode permission keys:
final PermissionCatalogue cat = await sso.permissions(); // cached
await sso.createRole(RoleDraft(
title: 'Senior Agent',
permission: expandPermissions(cat, <String>['CRM.CHAT.TRANSFER']),
dataScope: DataScope.own,
));
expandPermissions is not optional. Ticking CRM.CHAT.TRANSFER also requires
CRM.CHAT.LIST, which requires TEAM.LIST; the server stores exactly what it
is sent, so a stored role has to carry its own complete effective set.
SsoPermissionMatrix does this for you and shows the implied cells ticked and
locked, naming what pulled them in.
Two more things the API decides, not this package:
- Action keys only.
CRM.CONTACT.LIST, never the feature keyCRM.CONTACT— anything prefix-matching would read as a wildcard, so the server rejects it by name. - Omit
dataScopeunless the user changed it. A title-only edit that sent it would silently reset record access for every holder, which is whyRoleUpdateleaves a null field out of the request entirely.
Members and invitations follow the same shape. The distinction worth knowing is that a membership update tells three states apart:
await sso.updateMember(id, const MemberUpdate(
role: Patch<String>.clear(), // strip their permissions
// accessExpiry left alone entirely
));
Revoking a member removes this account from them, not their login: it disappears from their account picker on their next request, and re-inviting the same person restores the membership.
Invitations are not emailed. createInvitation returns a token once — it
never appears in the list again — and delivering it is your job.
inviteLinkBase is where it gets appended.
Billing and wallets
final SsoWallets w = await sso.wallets(); // from GET /me
final List<SsoTransaction> log = await sso.walletLogs();
final List<SsoPlan> plans = await sso.plans(); // plans and add-ons together
await sso.subscribe(planId: 'pro', duration: '30D');
final List<CreditWallet> credits = await sso.creditWallets();
await sso.rechargeCreditWallet(id, credits: 500, context: context);
Split plans from add-ons with SsoPlan.isAddon — the backend distinguishes
them only by a type string. Balances are not an endpoint of their own; they
ride along inside GET /me.
Some plans carry an external checkout link instead of being bought through
subscribe, and topping up a wallet means opening the project's own payment
page. This package takes no URL-launcher dependency, so those buttons appear
only when you pass onLaunchUrl.
A credit wallet's fundSource decides how it is topped up: coin buys credits
directly (with a captcha when the project requires one), main is filled by
subscribing to a plan.
Calling your own backend
Read the token and put it wherever your client wants it:
final String? token = sso.accessToken;
Or wrap your HTTP client so it is attached for you, with 401 handling included:
final api = AuthHttpClient(
token: () => sso.accessToken,
origins: <String>['https://api.yourapp.com'],
onUnauthorized: (_) => goToLogin(),
onAccountAccessLost: (_) => showAccountPicker(),
);
final res = await api.get(Uri.parse('https://api.yourapp.com/orders'));
The origins allowlist is mandatory and has no wildcard. A client that
attached a bearer token to every request would hand your session to analytics
endpoints, CDNs, image hosts and any third-party SDK sharing that client.
Anything not on the list goes through untouched, and an Authorization header
you set yourself is never overwritten.
Token storage
By default the token lives in the platform keychain or keystore, under
ab_sso_access_token. Every operation is guarded and falls back to an
in-memory copy: secure storage genuinely fails on some devices, and a throwing
read would turn "we could not persist your session" into "the app crashes on
launch".
SsoConfig(..., tokenStore: MemoryTokenStore()) // no persistence
SsoConfig(..., tokenStore: SecureTokenStore(key: 'my_key')) // match an existing key
Implement TokenStore for anything else. Implementations must not throw.
Permissions
GET /me reports whether the session owns the account it is in or is a member
of someone else's:
final AccountContext? ctx = await sso.accountContext();
if (sso.can('BILLING.VIEW')) showBilling();
Owners pass every check. So does a session with no context — an older backend or a plain single-account session is treated as the owner, matching the web SDK. This is for shaping your UI; the API enforces the real rules.
Errors
Everything thrown is an SsoException, and message is always safe to show —
the backend writes those strings for end users.
| Type | Means |
|---|---|
SsoApiException |
The API rejected the call. Carries status and code. |
AccountAccessException |
A403 — this account is gone, the login is not. |
CaptchaRequiredException |
Captcha needed, no solver and no context. |
CaptchaCancelledException |
The user dismissed the captcha sheet. |
CaptchaFailedException |
The provider errored, expired or timed out. |
SsoNetworkException |
The request never got there. |
Two quirks the SDK absorbs, both inherited from the API: most business errors
arrive as HTTP 200 with success: false, and the message sits under
message on most routes but under data on others. A plain statusCode == 200
check would read "Only the account owner can do this" as a success. Both are
handled centrally and covered by tests.
Theming and text
The widgets take colour, shape and typography from Theme.of(context), so they
match your app with no configuration, in light and dark. The project's own
auth-config supplies the brand logo and decides whether signup,
forgot-password and the legal links appear.
All copy lives in SsoStrings:
SsoAuthFlow(
onSignedIn: ...,
strings: const SsoStrings(signIn: 'Log in', email: 'Work email'),
)
Terms and privacy links are only rendered if you supply onLaunchUrl, since
this package takes no URL-launcher dependency:
SsoAuthFlow(onSignedIn: ..., onLaunchUrl: (Uri url) => launchUrl(url))
State management
None is imposed. sso.session is a ValueListenable, which every approach
already knows how to consume:
// Provider
ChangeNotifierProvider(...) // or just ValueListenableBuilder
// Riverpod
final sessionProvider = StreamProvider<SsoSession>((ref) { ... });
// Bloc
// map sso.session changes into events
ValueListenableBuilder and SsoScope.sessionOf(context) work with no extra
package at all.
What this version does not cover
- Email links. Reset-password, verify-email and invitation links are not
received or parsed here — your app's deep linking owns that. The endpoints
are available (
resetPassword,verifyEmail,getInvitation,acceptInvitation) once you have a token from a link. - Opening external URLs. Checkout links, wallet top-ups and the legal links
need
onLaunchUrl, because this package takes no URL-launcher dependency. - Markdown in plan descriptions. The web SDK renders them as markdown; here they are shown as written.
- Date and number localisation. Dates render as
02 Jun 2026and amounts group with commas, spelled out rather than taken fromintl. The models expose the raw values (DateTime,num) if you want your locale's format.
API summary
SsoClient(config) |
One per app. |
init() / revalidate() |
Load and check the session. |
login() / verifyTwoFactor() |
Sign in; returns a sealed LoginResult. |
signup() |
Create an account; returns a sealed SignupResult. |
forgotPassword() |
Send a reset email. |
resetPassword() / verifyEmail() |
Act on a token from a link you received. |
changePassword() |
Needs the current password. |
logout() |
Clears the session locally even if the call fails. |
selectAccount() / switchAccount() / listAccounts() |
Multi-account. |
getInvitation() / acceptInvitation() |
Invitations. |
profile() / rawUser() / accountContext() / can() |
The current user. |
authConfig() / currencies() |
Project configuration. |
session / accessToken / isSignedIn |
Session state. |
updateProfile() |
PUT /profile — replaces the profile object. |
twoFactorEnabled() / beginTwoFactor() / confirmTwoFactor() / disableTwoFactor() |
2FA enrolment. |
whatsAppStatus() / enableWhatsApp() / verifyWhatsApp() |
WhatsApp as a factor. |
wallets() / walletLogs() / rechargeLinks() |
Balances and transactions. |
plans() / subscribe() / subscription() |
Plans and add-ons. |
creditWallets() / creditWalletLogs() / rechargeCreditWallet() |
Credits. |
permissions() |
The permission catalogue. Cached. |
roles() / createRole() / updateRole() / deleteRole() |
Roles. Owner-only. |
members() / updateMember() / revokeMember() |
Memberships. Owner-only. |
team() |
The member's read-only view of their colleagues. |
invitations() / createInvitation() / revokeInvitation() |
Invitations. Owner-only. |
License
MIT
Libraries
- ab_ecosystem_sso
- Flutter SDK for AB Ecosystem SSO.