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

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 key CRM.CONTACT — anything prefix-matching would read as a wildcard, so the server rejects it by name.
  • Omit dataScope unless the user changed it. A title-only edit that sent it would silently reset record access for every holder, which is why RoleUpdate leaves 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 2026 and amounts group with commas, spelled out rather than taken from intl. 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.