ghayma_auth 0.1.0
ghayma_auth: ^0.1.0 copied to clipboard
Client for the Ghayma auth service: sign-in, sessions, 2FA, PKCE OAuth and native Google sign-in for Dart and Flutter apps.
ghayma_auth #
Dart client for the Ghayma auth service: email/password sign-in, 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 server. Flutter integrates through
two seams: a TokenStorage adapter for persistence, and handing the OAuth
redirect URI to handleRedirect.
Install #
dart pub add ghayma_auth
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: 'user@example.com',
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.
See example/main.dart for a runnable walkthrough.
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;
}
});
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.
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));
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).
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);
}
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: https://docs.ghayma.cloud/guides/oauth-mobile.
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. Never ship a server key
to a client app.
Errors #
Every failure is a GhaymaAuthException with a status, a code, a message
and, on rate limits, retryAfter in seconds.
code |
When |
|---|---|
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 |
rate_limited |
429; read retryAfter |
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 |
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.
Contract and conformance #
Every request body, response field and error string in this package comes from
the published contract, vendored at
spec/auth.v1.yaml and refreshed with tool/sync_spec.sh
from https://auth.ghayma.tech/openapi.yaml. CI fails when the copy is stale.
Two test layers:
dart test # unit tests against a mock transport
tool/conformance.sh # every method against a Prism mock of the contract
The conformance suite starts @stoplight/prism-cli on the vendored spec with
--errors, so an off-contract request fails the run rather than passing
silently. It needs Node 22 with npx.
Development #
dart pub get
dart format --output=none --set-exit-if-changed .
dart analyze --fatal-infos
dart test
tool/conformance.sh
License #
MIT — see LICENSE.