Skip to Content
SDK ReferenceDart / Flutter SDK

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_auth

In 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 );
OptionTypeDefaultDescription
appSlugStringRequired. Your auth app’s slug (app_id in the console)
baseUrlStringhttps://auth.ghayma.techAuth service URL; trailing slashes are trimmed
storageTokenStorage?InMemoryTokenStorage()Where the session is kept between runs
autoRefreshbooltrueRotate the access token shortly before it expires
serverKeyString?Server-side only. Lets you forward each end user’s IP — see Server Usage
httpClienthttp.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 stream

init() 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.

AuthEventEmitted when
signedIna login, registration, 2FA verification, code exchange or native sign-in produced a session
signedOutlogout, deleteAccount, changePassword, or a refresh the service rejected
tokenRefreshedthe access token rotated
userUpdatedupdateUser 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 nothing

Only 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.

codeWhen
rate_limited429; read retryAfter
invalid_requestthe request was malformed or a field was rejected
invalid_granta spent, expired or mismatched one-time code at exchangeCode
invalid_tokena provider ID token that failed verification
oauth_errorthe provider handed back ?error= on the redirect
network_errorthe request never reached the service (status 0)
timeoutno answer within 30 s (status 408)
auth_erroranything 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 handleRedirectinvalid_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.