Dart / Flutter SDK
ghayma_auth is the Dart client for the Ghayma auth service: the code that
signs the end users of your application in. It covers login, registration,
sessions with automatic refresh, TOTP two-factor, profile management, PKCE
OAuth and native Google sign-in.
Pure Dart — http and crypto only, no Flutter dependency — so the same
client runs in a Flutter app, a CLI and a Dart server. Flutter integrates
through two seams: a TokenStorage adapter for persistence, and handing the
OAuth redirect URI to handleRedirect.
This package authenticates with your app slug alone and never sees a project
API key. For server-side administration — listing users, disabling them,
setting app_metadata — use the Server SDK. The surface here
mirrors the Client SDK (browser), in Dart idioms.
The package is ghayma_auth on pub.dev;
the source lives in Ghayma-Dart-SDK .
Installation
dart pub add ghayma_authIn a Flutter app, flutter pub add ghayma_auth. Dart 3.6 or newer: login and
register answer with sealed types, so the compiler checks that you handled
every branch.
Quick Start
import 'package:ghayma_auth/ghayma_auth.dart';
final auth = GhaymaAuth(appSlug: 'my-app');
await auth.init(); // restore a persisted session
final result = await auth.login(
email: '[email protected]',
password: 's3cret-passphrase',
);
switch (result) {
case LoginSuccess(:final session):
print('signed in as ${session.user.email}');
case TwoFaRequired(:final challengeToken):
await auth.verify2fa(challengeToken: challengeToken, code: '123456');
case TwoFaEnrollmentRequired(:final enrollToken):
final enrollment = await auth.enrollTotp(enrollToken: enrollToken);
final confirmation = await auth.confirmTotp(
code: '123456',
enrollToken: enrollToken,
);
print('recovery codes: ${confirmation.recoveryCodes}');
}
final user = await auth.getUser();
await auth.logout();
auth.dispose();register answers the same way: RegisterSuccess when tokens are issued,
VerificationRequired when the app requires a verified email first.
Configuration
final auth = GhaymaAuth(
appSlug: 'my-app', // required
baseUrl: 'https://auth.ghayma.tech', // default
storage: SecureTokenStorage(), // default: InMemoryTokenStorage()
autoRefresh: true, // default
);| Option | Type | Default | Description |
|---|---|---|---|
appSlug | String | — | Required. Your auth app’s slug (app_id in the console) |
baseUrl | String | https://auth.ghayma.tech | Auth service URL; trailing slashes are trimmed |
storage | TokenStorage? | InMemoryTokenStorage() | Where the session is kept between runs |
autoRefresh | bool | true | Rotate the access token shortly before it expires |
serverKey | String? | — | Server-side only. Lets you forward each end user’s IP — see Server Usage |
httpClient | http.Client? | — | Bring your own client. dispose() only closes one the SDK created |
Where appSlug comes from
Every site of a project that has an auth app gets the id in its environment as
GHAYMA_AUTH_APP_ID — see Automatically injected
variables. Read it with
Platform.environment on a server; in a Flutter app pass it at build time
(--dart-define=GHAYMA_AUTH_APP_ID=my-app) and read it with
String.fromEnvironment.
Sessions
await auth.init(); // restore a persisted session and arm auto-refresh
if (auth.isAuthenticated) {
final token = await auth.getAccessToken();
await http.get(uri, headers: {'Authorization': 'Bearer $token'});
}
await auth.logout(); // revokes the refresh token, then clears local state
auth.dispose(); // cancels the refresh timer and closes the event streaminit() restores from storage without emitting an event — read
isAuthenticated (or currentSession / currentUser) once it resolves.
getAccessToken() refreshes when the token is within 30 s of expiry, and a
background timer rotates it 60 s before it lapses, so calling it before each
API request is enough. With no session at all it throws a 401.
refresh() rotates the pair by hand. Concurrent callers share one rotation, so
the single-use refresh token is never spent twice. A refresh the service
rejects — 401, or 403 for a reused token, which revokes every session of the
account — clears the session and emits signedOut before the error reaches
you.
Auth state events
onAuthStateChange is a broadcast Stream<AuthState>; each event carries the
session as it stands afterwards, null once signed out.
AuthEvent | Emitted when |
|---|---|
signedIn | a login, registration, 2FA verification, code exchange or native sign-in produced a session |
signedOut | logout, deleteAccount, changePassword, or a refresh the service rejected |
tokenRefreshed | the access token rotated |
userUpdated | updateUser wrote a profile change |
Flutter Integration
Persist the session
The default InMemoryTokenStorage forgets everything when the process exits.
Implement TokenStorage over the store of your choice — here
flutter_secure_storage:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:ghayma_auth/ghayma_auth.dart';
class SecureTokenStorage implements TokenStorage {
static const _key = 'ghayma_session';
final _storage = const FlutterSecureStorage();
@override
Future<String?> read() => _storage.read(key: _key);
@override
Future<void> write(String value) => _storage.write(key: _key, value: value);
@override
Future<void> delete() => _storage.delete(key: _key);
}
final auth = GhaymaAuth(appSlug: 'my-app', storage: SecureTokenStorage());Startup and routing
Call init() once before the first frame and route on onAuthStateChange
afterwards:
await auth.init();
runApp(MyApp(signedIn: auth.isAuthenticated));
auth.onAuthStateChange.listen((state) {
switch (state.event) {
case AuthEvent.signedIn:
router.go('/home');
case AuthEvent.signedOut:
router.go('/login');
case AuthEvent.tokenRefreshed:
case AuthEvent.userUpdated:
break;
}
});OAuth in a browser tab
PKCE only: no token ever travels through the OS URL handler. Open the URL with
flutter_web_auth_2 5.x and hand
the callback straight back:
final start = await auth.startOAuth(
OAuthProvider.google,
redirectUri: 'com.example.app://callback',
);
final result = await FlutterWebAuth2.authenticate(
url: start.url,
callbackUrlScheme: 'com.example.app',
);
await auth.handleRedirect(Uri.parse(result));OAuthProvider.github works the same way. startOAuth keeps the verifier in
memory and also returns it in OAuthStart.codeVerifier, so an app that hands
off to an external browser can persist it and pass it back:
handleRedirect(uri, codeVerifier: saved). If you would rather build the URL
yourself, oauthUrl(provider, redirectUri: …, codeChallenge: …) returns it and
exchangeCode(code: …, codeVerifier: …) finishes the trade.
Native Google sign-in
With google_sign_in 7.x the
platform hands you an ID token and no browser is involved:
final account = await GoogleSignIn.instance.authenticate();
final idToken = account.authentication.idToken;
if (idToken != null) {
await auth.signInWithIdToken(idToken: idToken);
}Pass nonce: when you set one on the request; it must match the token’s claim.
A token that fails verification comes back as invalid_token.
Console setup
In the Ghayma console, for your auth app:
- Allowed Origins must list the deep link you pass as
redirectUri(com.example.app://callback), exactly as written. - Native client IDs must list the iOS and Android OAuth client ids you use
with
google_sign_in; the service verifies the ID token against them.
Full walkthrough, including verified links and the association files they need: OAuth on Mobile.
Two-Factor Authentication
When the auth app has 2FA enabled, login no longer always answers with a
session — the sealed LoginResult makes the other two shapes part of the type:
switch (await auth.login(email: email, password: password)) {
case LoginSuccess(:final session):
// Signed in — no second factor applies.
case TwoFaRequired(:final challengeToken, :final methods):
// Enrolled user: a TOTP code, or one of their recovery codes.
await auth.verify2fa(challengeToken: challengeToken, code: userInput);
case TwoFaEnrollmentRequired(:final enrollToken):
// The app enforces 2FA and this user is not enrolled yet.
final enrollment = await auth.enrollTotp(enrollToken: enrollToken);
// Render enrollment.otpauthUri as a QR code, or show enrollment.secret.
final confirmation = await auth.confirmTotp(
code: userInput,
enrollToken: enrollToken,
);
// confirmation.recoveryCodes is shown once; the login is complete.
}The challenge expires in five minutes and burns after five wrong codes — see the browser SDK for the rest of the policy.
A signed-in user enrols the same way with no token, and manages the factor from there:
final enrollment = await auth.enrollTotp();
final confirmation = await auth.confirmTotp(code: '123456');
// confirmation.recoveryCodes — single-use, shown this once and never again.
final codes = await auth.regenerateRecoveryCodes(
password: password,
code: currentCode, // TOTP or an unused recovery code
);
await auth.disable2fa(password: password, code: currentCode);getUser() exposes recoveryCodesLeft, so you can prompt for regeneration
before the user runs out.
Profile
final user = await auth.getUser(); // also refreshes currentUser
print('${user.email} — verified: ${user.emailVerified}, TOTP: ${user.totpEnabled}');
await auth.updateUser(
name: 'Jane Doe',
avatarUrl: 'https://example.com/photo.jpg',
metadata: {'theme': 'dark'},
);
// Every session is revoked, this one included, so the local session goes too.
await auth.changePassword(currentPassword: old, newPassword: fresh);
// The address only moves once the emailed link is opened; change.expiresAt
// says when that link stops working.
final change = await auth.changeEmail(
newEmail: '[email protected]',
currentPassword: password,
);
await auth.cancelEmailChange();
await auth.deleteAccount(password: password); // provider accounts pass nothingOnly fields you pass are updated. metadata is the user-owned bag; roles,
plans and tenant ids belong in appMetadata, which is read-only here and
written only by the Server SDK.
Password Recovery
await auth.forgotPassword(email: email);
// On your reset page: check the token before asking for a new password.
final info = await auth.verifyResetToken(token: token);
if (info.valid) {
await auth.resetPassword(token: token, password: newPassword);
}
await auth.resendVerification(email: email);forgotPassword and resendVerification answer the same way whether or not
the address is registered. Reset emails can point at a page of your own instead
of the hosted form — see Custom Reset
Page.
Server Usage
On a Dart server, pass the app’s server key (ghs_…) and forward the end
user’s IP so rate limits are charged to that address rather than to your
server:
final auth = GhaymaAuth(appSlug: 'my-app', serverKey: Platform.environment['GHAYMA_SERVER_KEY']);
await auth.login(
email: email,
password: password,
options: RequestOptions(clientIp: request.remoteAddress.address),
);The two headers travel together or not at all: without a serverKey the
clientIp is dropped, and anything that is not a bare IP literal (a whole
x-forwarded-for chain, for instance) is dropped too. RequestOptions is
accepted on the calls the service limits by address — register, login,
forgotPassword, resetPassword, resendVerification, exchangeCode and
signInWithIdToken.
Never ship a server key to a client app. On Ghayma the key is injected into every site of the project rather than stored with your own variables — see Where the key comes from for the variable names and rotation.
Errors
Every failure is a GhaymaAuthException with a status, a code, a message
and, on rate limits, retryAfter in seconds.
code | When |
|---|---|
rate_limited | 429; read retryAfter |
invalid_request | the request was malformed or a field was rejected |
invalid_grant | a spent, expired or mismatched one-time code at exchangeCode |
invalid_token | a provider ID token that failed verification |
oauth_error | the provider handed back ?error= on the redirect |
network_error | the request never reached the service (status 0) |
timeout | no answer within 30 s (status 408) |
auth_error | anything the service did not label — a wrong password or 2FA code is a plain 401 here, a missing session is 401 before any request |
The first four are the service’s own code field. The rest the SDK raises
itself: oauth_error when the redirect carries ?error=, and — from
handleRedirect — invalid_request when there is no code in the redirect,
invalid_grant when no PKCE verifier is available for it.
try {
await auth.login(email: email, password: password);
} on GhaymaAuthException catch (err) {
if (err.code == 'rate_limited') {
print('try again in ${err.retryAfter} s');
}
}A refresh the service rejects clears the session and emits
AuthEvent.signedOut before the error reaches you. The per-endpoint throttles
are the service’s, so they are the same ones the browser SDK documents under
Rate Limits.
Contract Conformance
Every request body, response field and error string in the package comes from
the Auth Service API contract, vendored in the repository
and refreshed from the document the service publishes — CI fails when the copy
goes stale. On top of the unit suite, every method runs against a Prism mock of
that document started with --errors, so an off-contract request fails the run
rather than passing silently.